Merge remote-tracking branch 'knoe/main'

This commit is contained in:
chrisfu 2026-04-28 00:05:40 -07:00
commit bb44074b1b
62 changed files with 4356 additions and 1148 deletions

View File

@ -0,0 +1,29 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — all (with coverage)" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.14 (knoe-db)" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="DEBUG_JUST_MY_CODE" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="--cov=knoe --cov-report=html --cov-report=term-missing tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — gke mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="gke" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m gke --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — k3d mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="k3d" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m k3d --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — min mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="min" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m min --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — unit (fast, no deps)" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m &quot;unit and not integration&quot; --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<a href="https://svc.knoe.org"><pre> <a href="https://svc.prole.org"><pre>
# ########################### # ###########################
# ╭──────────────────────╮ # # ╭──────────────────────╮ #
# │ _ │ # # │ _ │ #

View File

@ -62,6 +62,39 @@
<version>0.11.5</version> <version>0.11.5</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- ── knoe-auth Round 1: enrollment dependencies ────────────────────── -->
<!-- TOTP (RFC 6238 — Google Authenticator compatible) -->
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp-spring-boot-starter</artifactId>
<version>1.7.1</version>
</dependency>
<!-- Google OAuth2 ID token verification -->
<dependency>
<groupId>com.google.api-client</groupId>
<artifactId>google-api-client</artifactId>
<version>2.4.0</version>
</dependency>
<!-- Database access (CNPG / PostgreSQL) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- BCrypt for OTP hashing (backup code hashing) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -0,0 +1,116 @@
package org.prole.authority.admin;
import org.prole.authority.enroll.InviteService;
import org.prole.authority.enroll.InviteService.InviteResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* AdminController internal API for knoe.dev admin operations.
*
* Endpoints:
* POST /auth/admin/invites create a new contributor invite
* GET /auth/admin/users list knoe.user rows
* POST /auth/admin/grants grant knobject access to a user
*
* Authentication: expects a Bearer token in the Authorization header.
* For Round 1 this is validated against a fixed admin session token stored
* in knoe-auth-secrets. Full RBAC is a Round 2 concern.
*
* NOTE: All endpoints return JSON. The admin token check is intentionally
* simple for Round 1 replace with proper session/role check in Round 2.
*/
@RestController
@RequestMapping("/auth/admin")
public class AdminController {
private static final Logger log = LoggerFactory.getLogger(AdminController.class);
private final InviteService inviteService;
private final KnobjectService knobjectService;
public AdminController(InviteService inviteService, KnobjectService knobjectService) {
this.inviteService = inviteService;
this.knobjectService = knobjectService;
}
// Invites
/**
* Create a new contributor invite.
*
* Request body: { "contact": "email@example.com", "contactType": "email", "nameHint": "..." }
* Response: { "token": "...", "enrollUrl": "...", "otp": "..." }
*
* The caller (admin) is responsible for sending the OTP to the contact via
* the stated contactType channel. The invite system itself does not send email.
*/
@PostMapping(
path = "/invites",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<Map<String, String>> createInvite(
@RequestBody CreateInviteRequest req,
@RequestHeader(value = "X-Knoe-Admin", required = false) String adminHint) {
// Round 1: accept any authenticated request admin auth enforcement in Round 2
String createdBy = adminHint != null ? adminHint : "admin";
log.info("Admin {} creating invite for contact={} type={}", createdBy, req.contact(), req.contactType());
InviteResult result = inviteService.createInvite(
req.contact(),
req.contactType() != null ? req.contactType() : "email",
req.nameHint(),
createdBy
);
return ResponseEntity.ok(Map.of(
"token", result.token(),
"enrollUrl", result.enrollUrl(),
"otp", result.rawOtp() // admin MUST send this to the contact
));
}
// Users
/**
* List all knoe users (id, username, email, realm, created_at).
*/
@GetMapping(path = "/users", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> listUsers() {
return ResponseEntity.ok(knobjectService.listUsers());
}
// Access grants
/**
* Grant a user access to a knobject.
*
* Request body: { "userId": 1, "knobjectId": 2, "role": "developer", "grantedBy": "admin" }
*/
@PostMapping(
path = "/grants",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<Map<String, Object>> createGrant(@RequestBody GrantRequest req) {
log.info("Granting {} access to knobject {} role={} by {}",
req.userId(), req.knobjectId(), req.role(), req.grantedBy());
int grantId = knobjectService.grantAccess(req.userId(), req.knobjectId(),
req.role(), req.grantedBy());
return ResponseEntity.ok(Map.of("grantId", grantId, "status", "granted"));
}
// Request records
public record CreateInviteRequest(String contact, String contactType, String nameHint) {}
public record GrantRequest(int userId, int knobjectId, String role, String grantedBy) {}
}

View File

@ -0,0 +1,146 @@
package org.prole.authority.admin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Service;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.List;
import java.util.Map;
/**
* KnobjectService CRUD on knoe.knobject, knoe.access_grant, and knoe.provisioning_job.
*
* A "knobject" is a platform-managed resource: a Gitea repo, GitLab project,
* CNPG database role, or OpenBao policy. Access grants map users to knobjects
* with a role (owner | developer | viewer).
*/
@Service
public class KnobjectService {
private static final Logger log = LoggerFactory.getLogger(KnobjectService.class);
private final JdbcTemplate db;
public KnobjectService(JdbcTemplate db) {
this.db = db;
}
// Users
public List<Map<String, Object>> listUsers() {
return db.queryForList("""
SELECT id, username, email, realm, created_at
FROM knoe.user
ORDER BY created_at DESC
""");
}
// Knobjects
public List<Map<String, Object>> listKnobjects() {
return db.queryForList("SELECT * FROM knoe.knobject ORDER BY type, name");
}
public int createKnobject(String type, String name, String platformId, String metadataJson) {
var keyHolder = new GeneratedKeyHolder();
db.update(con -> {
PreparedStatement ps = con.prepareStatement("""
INSERT INTO knoe.knobject (type, name, platform_id, metadata)
VALUES (?, ?, ?, ?::jsonb)
RETURNING id
""", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, type);
ps.setString(2, name);
ps.setString(3, platformId);
ps.setString(4, metadataJson);
return ps;
}, keyHolder);
int id = ((Number) keyHolder.getKeys().get("id")).intValue();
log.info("Created knobject type={} name={} id={}", type, name, id);
return id;
}
public void updateKnobjectPlatformId(int id, String platformId) {
db.update("UPDATE knoe.knobject SET platform_id = ? WHERE id = ?", platformId, id);
}
// Access grants
/**
* Grant a user access to a knobject.
*
* @return the new grant id
*/
public int grantAccess(int userId, int knobjectId, String role, String grantedBy) {
var keyHolder = new GeneratedKeyHolder();
db.update(con -> {
PreparedStatement ps = con.prepareStatement("""
INSERT INTO knoe.access_grant (user_id, knobject_id, role, granted_by)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, knobject_id)
DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by,
revoked_at = NULL
RETURNING id
""", Statement.RETURN_GENERATED_KEYS);
ps.setInt(1, userId);
ps.setInt(2, knobjectId);
ps.setString(3, role);
ps.setString(4, grantedBy);
return ps;
}, keyHolder);
int grantId = ((Number) keyHolder.getKeys().get("id")).intValue();
log.info("Granted userId={} knobjectId={} role={}", userId, knobjectId, role);
// Queue provisioning if not already done
enqueueGrantProvisioning(userId, knobjectId, role);
return grantId;
}
public void revokeAccess(int userId, int knobjectId) {
db.update("""
UPDATE knoe.access_grant SET revoked_at = now()
WHERE user_id = ? AND knobject_id = ? AND revoked_at IS NULL
""", userId, knobjectId);
log.info("Revoked access: userId={} knobjectId={}", userId, knobjectId);
}
public List<Map<String, Object>> listGrants(int userId) {
return db.queryForList("""
SELECT ag.*, ko.type, ko.name
FROM knoe.access_grant ag
JOIN knoe.knobject ko ON ko.id = ag.knobject_id
WHERE ag.user_id = ? AND ag.revoked_at IS NULL
""", userId);
}
// Provisioning
private void enqueueGrantProvisioning(int userId, int knobjectId, String role) {
// Look up knobject type to determine job type
var rows = db.queryForList("SELECT type, name FROM knoe.knobject WHERE id = ?", knobjectId);
if (rows.isEmpty()) return;
String type = (String) rows.get(0).get("type");
String name = (String) rows.get(0).get("name");
String jobType = switch (type) {
case "gitea_repo" -> "grant_gitea_access";
case "gitlab_project"-> "grant_gitlab_access";
case "cnpg_role" -> "grant_cnpg_role";
case "openbao_policy"-> "grant_openbao_policy";
default -> null;
};
if (jobType == null) return;
String payload = "{\"userId\":" + userId + ",\"knobjectId\":" + knobjectId
+ ",\"knobjectName\":\"" + name + "\",\"role\":\"" + role + "\"}";
db.update("""
INSERT INTO knoe.provisioning_job (user_id, job_type, status, payload, created_at, updated_at)
VALUES (?, ?, 'pending', ?::jsonb, now(), now())
""", userId, jobType, payload);
}
}

View File

@ -0,0 +1,355 @@
package org.prole.authority.enroll;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.security.SecureRandom;
import java.util.Map;
import java.util.UUID;
/**
* EnrollmentController handles the 4-step contributor enrollment flow.
*
* Step 0: GET /auth/enroll?token=<uuid> landing / OTP entry
* Step 1: POST /auth/enroll/verify-otp prove contact ownership
* Step 2a: POST /auth/enroll/identity/start store PII, redirect to Google
* Step 2b: GET /auth/enroll/google-callback receive Google identity
* Step 3: GET /auth/enroll/totp TOTP QR code display
* Step 3v: POST /auth/enroll/totp/verify verify TOTP code
* Step 4: POST /auth/enroll/complete provision everything
*
* Session attributes used:
* enroll.token invite token
* enroll.otpVerified boolean gate
* enroll.username chosen username
* enroll.displayName display name
* enroll.googleState OAuth2 CSRF state
* enroll.googleNonce ID token nonce
* enroll.googleId GoogleOAuthService.GoogleIdentity (after callback)
* enroll.totpSecret plaintext TOTP secret (cleared after provisioning)
* enroll.totpVerified boolean gate
*/
@RestController
@RequestMapping("/auth/enroll")
public class EnrollmentController {
private static final Logger log = LoggerFactory.getLogger(EnrollmentController.class);
private final InviteService inviteService;
private final GoogleOAuthService googleOAuth;
private final TotpService totpService;
private final UserProvisioningService provisioning;
private final SecureRandom rng = new SecureRandom();
public EnrollmentController(InviteService inviteService,
GoogleOAuthService googleOAuth,
TotpService totpService,
UserProvisioningService provisioning) {
this.inviteService = inviteService;
this.googleOAuth = googleOAuth;
this.totpService = totpService;
this.provisioning = provisioning;
}
// Step 0: Landing page
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> landing(@RequestParam String token, HttpSession session) {
var invite = inviteService.findValidInvite(token);
if (invite == null) {
return html(HttpStatus.BAD_REQUEST, errorPage("Invalid or expired invite link.",
"This invite has expired or has already been used. Contact your admin for a new one."));
}
session.setAttribute("enroll.token", token);
String nameHint = (String) invite.getOrDefault("name_hint", "");
String contact = (String) invite.get("contact");
return html(HttpStatus.OK, """
<!DOCTYPE html><html><head><title>Join Knoe.DEV</title>%s</head><body>
<div class="card">
<h1>You've been invited to knoe.dev</h1>
<p>Welcome%s. To get started, enter the verification code that was sent to
<strong>%s</strong>.</p>
<form method="POST" action="/auth/enroll/verify-otp">
<label for="otp">Verification code</label>
<input id="otp" name="otp" type="text" inputmode="numeric"
pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code"
placeholder="6-digit code" required autofocus />
<input type="hidden" name="token" value="%s" />
<button type="submit">Verify &amp; Continue</button>
</form>
</div></body></html>
""".formatted(COMMON_STYLE, nameHint.isBlank() ? "" : (", " + nameHint), contact, token));
}
// Step 1: Verify OTP
@PostMapping(path = "/verify-otp", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<String> verifyOtp(@RequestParam String token,
@RequestParam String otp,
HttpSession session) {
var result = inviteService.verifyOtp(token, otp);
return switch (result) {
case OK -> {
session.setAttribute("enroll.token", token);
session.setAttribute("enroll.otpVerified", Boolean.TRUE);
yield redirectTo("/auth/enroll/identity");
}
case WRONG_OTP -> html(HttpStatus.BAD_REQUEST, errorPage("Incorrect code",
"That code is wrong. Please check and try again. You have limited attempts."));
case OTP_EXPIRED -> html(HttpStatus.BAD_REQUEST, errorPage("Code expired",
"The verification code has expired. Contact your admin to resend the invite."));
case LOCKED -> html(HttpStatus.FORBIDDEN, errorPage("Too many attempts",
"This invite has been locked due to too many failed attempts. Contact your admin."));
case INVALID_TOKEN -> html(HttpStatus.BAD_REQUEST, errorPage("Invalid invite",
"This invite link is no longer valid."));
};
}
// Step 2a: PII + identity form
@GetMapping(path = "/identity", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> identityForm(HttpSession session) {
if (!Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified"))) {
return html(HttpStatus.FORBIDDEN, errorPage("Verification required",
"Please verify your invitation code first."));
}
return html(HttpStatus.OK, """
<!DOCTYPE html><html><head><title>Your Identity Knoe.DEV</title>%s</head><body>
<div class="card">
<h1>Step 1 of 3 Your details</h1>
<p>Choose your knoe.dev username and link your Google account for identity corroboration.</p>
<form method="POST" action="/auth/enroll/identity/start">
<label>Username (a-z, 0-9, hyphen)
<input name="username" type="text" pattern="[a-z0-9\\-]{3,30}"
required autofocus placeholder="e.g. chrisfu" />
</label>
<label>Display name
<input name="displayName" type="text" required placeholder="Chris Fu" />
</label>
<button type="submit">Continue with Google </button>
</form>
</div></body></html>
""".formatted(COMMON_STYLE));
}
@PostMapping(path = "/identity/start", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<String> identityStart(@RequestParam String username,
@RequestParam String displayName,
HttpSession session) {
if (!Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified"))) {
return html(HttpStatus.FORBIDDEN, errorPage("Verification required", "OTP not verified."));
}
// Basic username validation
if (!username.matches("[a-z0-9\\-]{3,30}")) {
return html(HttpStatus.BAD_REQUEST, errorPage("Invalid username",
"Username must be 3-30 characters: lowercase letters, digits, and hyphens."));
}
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
session.setAttribute("enroll.username", username);
session.setAttribute("enroll.displayName", displayName);
session.setAttribute("enroll.googleState", state);
session.setAttribute("enroll.googleNonce", nonce);
String authUrl = googleOAuth.buildAuthorizationUrl(state, nonce);
return redirectTo(authUrl);
}
// Step 2b: Google OAuth2 callback
@GetMapping(path = "/google-callback", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> googleCallback(@RequestParam String code,
@RequestParam String state,
HttpSession session) {
String expectedState = (String) session.getAttribute("enroll.googleState");
if (expectedState == null || !expectedState.equals(state)) {
return html(HttpStatus.BAD_REQUEST, errorPage("Security error",
"State mismatch — possible CSRF. Please start enrollment again."));
}
try {
var googleId = googleOAuth.exchangeCode(code);
session.setAttribute("enroll.googleId", googleId);
log.info("Google identity linked: sub={} email={} hd={}", googleId.sub(), googleId.email(), googleId.hd());
return redirectTo("/auth/enroll/totp");
} catch (GoogleOAuthService.GoogleOAuthException e) {
log.warn("Google OAuth callback failed: {}", e.getMessage());
return html(HttpStatus.BAD_REQUEST, errorPage("Google sign-in failed", e.getMessage()));
}
}
// Step 3: TOTP setup
@GetMapping(path = "/totp", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> totpSetup(HttpSession session) {
var googleId = (GoogleOAuthService.GoogleIdentity) session.getAttribute("enroll.googleId");
if (googleId == null) {
return html(HttpStatus.FORBIDDEN, errorPage("Step skipped",
"Please complete Google sign-in first."));
}
String username = (String) session.getAttribute("enroll.username");
String secret = totpService.generateSecret();
session.setAttribute("enroll.totpSecret", secret);
String qrUri = totpService.buildQrUri(username, secret);
return html(HttpStatus.OK, """
<!DOCTYPE html><html><head><title>Authenticator Setup Knoe.DEV</title>%s</head><body>
<div class="card">
<h1>Step 2 of 3 Set up your authenticator</h1>
<p>Open <strong>Google Authenticator</strong>, <strong>Authy</strong>, or any
compatible app and scan this QR code:</p>
<div class="qr-wrap">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=%s"
alt="TOTP QR code" width="220" height="220" />
</div>
<details><summary>Can't scan? Enter manually</summary>
<code class="manual-key">%s</code>
</details>
<form method="POST" action="/auth/enroll/totp/verify">
<label for="code">Enter the 6-digit code from your app</label>
<input id="code" name="code" type="text" inputmode="numeric"
pattern="[0-9]{6}" maxlength="6" required autofocus
autocomplete="one-time-code" placeholder="123456" />
<button type="submit">Verify &amp; Finish</button>
</form>
</div></body></html>
""".formatted(COMMON_STYLE, encode(qrUri), secret));
}
@PostMapping(path = "/totp/verify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<String> totpVerify(@RequestParam String code, HttpSession session) {
String secret = (String) session.getAttribute("enroll.totpSecret");
if (secret == null) {
return html(HttpStatus.FORBIDDEN, errorPage("Session expired", "Please start setup again."));
}
if (!totpService.verify(secret, code)) {
return html(HttpStatus.BAD_REQUEST, errorPage("Incorrect code",
"That code doesn't match. Make sure your device clock is correct and try again."));
}
session.setAttribute("enroll.totpVerified", Boolean.TRUE);
return redirectTo("/auth/enroll/complete");
}
// Step 4: Complete
@PostMapping(path = "/complete")
public ResponseEntity<String> complete(HttpSession session) {
String token = (String) session.getAttribute("enroll.token");
String username = (String) session.getAttribute("enroll.username");
String displayName = (String) session.getAttribute("enroll.displayName");
var googleId = (GoogleOAuthService.GoogleIdentity) session.getAttribute("enroll.googleId");
String totpSecret = (String) session.getAttribute("enroll.totpSecret");
boolean totpOk = Boolean.TRUE.equals(session.getAttribute("enroll.totpVerified"));
boolean otpOk = Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified"));
if (!otpOk || googleId == null || !totpOk || token == null || username == null) {
return html(HttpStatus.BAD_REQUEST, errorPage("Incomplete enrollment",
"Not all steps have been completed. Please start from the beginning."));
}
try {
var result = provisioning.provision(token, username, displayName,
googleId.email(), googleId, totpSecret);
// Clear sensitive session data
session.removeAttribute("enroll.totpSecret");
session.removeAttribute("enroll.googleId");
log.info("Enrollment complete for {}", username);
return html(HttpStatus.OK, """
<!DOCTYPE html><html><head><title>Welcome Knoe.DEV</title>%s</head><body>
<div class="card success">
<h1>Welcome to knoe.dev, %s!</h1>
<p>Your knoe.dev identity is ready.</p>
<ul>
<li><strong>Username:</strong> %s</li>
<li><strong>Kerberos principal:</strong> %s@KNOE.DEV</li>
<li><strong>Email:</strong> %s</li>
</ul>
<p>Your developer environment is being set up. You will receive an email
when your GitLab and Gitea accounts are ready.</p>
<p><a href="https://git.knoe.dev">git.knoe.dev</a></p>
</div></body></html>
""".formatted(COMMON_STYLE, displayName, username, username, googleId.email()));
} catch (UserProvisioningService.ProvisioningException e) {
log.error("Provisioning failed for {}: {}", username, e.getMessage(), e);
return html(HttpStatus.INTERNAL_SERVER_ERROR, errorPage("Setup failed",
"Something went wrong setting up your account. Your admin has been notified."));
}
}
// Helpers
private static ResponseEntity<String> html(HttpStatus status, String body) {
return ResponseEntity.status(status)
.contentType(MediaType.TEXT_HTML)
.body(body);
}
private static ResponseEntity<String> redirectTo(String location) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, location)
.build();
}
private static String errorPage(String title, String detail) {
return """
<!DOCTYPE html><html><head><title>%s Knoe.DEV</title>%s</head><body>
<div class="card error">
<h1>%s</h1>
<p>%s</p>
<p><a href="/">Return to start</a></p>
</div></body></html>
""".formatted(title, COMMON_STYLE, title, detail);
}
private static String encode(String value) {
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
}
private static final String COMMON_STYLE = """
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:#f0f2f5;display:flex;justify-content:center;align-items:flex-start;
min-height:100vh;padding:3rem 1rem}
.card{background:#fff;border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,.1);
max-width:480px;width:100%;padding:2.5rem}
h1{font-size:1.5rem;margin-bottom:1rem;color:#1a1a2e}
p{color:#444;line-height:1.6;margin-bottom:1rem}
label{display:block;margin-bottom:1.2rem;color:#333;font-weight:500}
input[type=text]{display:block;width:100%;margin-top:.4rem;padding:.7rem .9rem;
border:1.5px solid #ddd;border-radius:7px;font-size:1.1rem;
transition:border-color .2s}
input[type=text]:focus{border-color:#4a90d9;outline:none}
button{width:100%;padding:.85rem;background:#1a1a2e;color:#fff;
border:none;border-radius:7px;font-size:1rem;cursor:pointer;margin-top:.5rem}
button:hover{background:#2d2d5e}
.qr-wrap{text-align:center;margin:1.5rem 0}
.manual-key{font-family:monospace;word-break:break-all;font-size:.9rem}
details{margin-bottom:1.2rem;color:#555}
.error h1{color:#c0392b}
.success h1{color:#27ae60}
ul{margin:.5rem 0 1rem 1.2rem;color:#444;line-height:1.8}
a{color:#4a90d9}
</style>
""";
}

View File

@ -0,0 +1,150 @@
package org.prole.authority.enroll;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Collections;
/**
* GoogleOAuthService exchanges OAuth2 authorization code for a verified Google ID token.
*
* No hd= restriction is applied. Any Google account is accepted for corroboration.
* The developer's home domain (prole.org, gmail.com, etc.) is recorded in
* knoe.identity.provider_hd for audit purposes only it does not gate access.
*
* Trust is established by the admin-controlled invite + OTP, not by the home domain.
*/
@Service
public class GoogleOAuthService {
private static final Logger log = LoggerFactory.getLogger(GoogleOAuthService.class);
private static final String TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
private static final String AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
@Value("${knoe.google.clientId:}")
private String clientId;
@Value("${knoe.google.clientSecret:}")
private String clientSecret;
@Value("${knoe.google.redirectUri:https://auth.knoe.dev/auth/enroll/google-callback}")
private String redirectUri;
/**
* Build the Google OAuth2 authorization URL.
*
* @param state session-bound CSRF state
* @param nonce nonce for id_token replay protection
*/
public String buildAuthorizationUrl(String state, String nonce) {
return UriComponentsBuilder.fromHttpUrl(AUTH_ENDPOINT)
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("response_type", "code")
.queryParam("scope", "openid email profile")
.queryParam("state", state)
.queryParam("nonce", nonce)
.queryParam("access_type", "online")
// NOTE: No hd= parameter any Google Workspace or gmail.com account is accepted.
// The invite OTP is the trust anchor; Google is corroboration only.
.build()
.toUriString();
}
/**
* Exchange the authorization code for a verified GoogleIdentity.
*
* @param code the OAuth2 authorization code from the callback
* @return GoogleIdentity with sub, email, name, hd (may be null for gmail.com)
* @throws GoogleOAuthException on any error
*/
public GoogleIdentity exchangeCode(String code) {
if (clientId == null || clientId.isBlank()) {
throw new GoogleOAuthException("Google OAuth2 is not configured (GOOGLE_CLIENT_ID not set)");
}
// Exchange code for tokens via HTTP POST
try {
var transport = new NetHttpTransport();
var factory = GsonFactory.getDefaultInstance();
// Token exchange
var tokenRequest = new com.google.api.client.http.GenericUrl(TOKEN_ENDPOINT);
String body = "code=" + encode(code)
+ "&client_id=" + encode(clientId)
+ "&client_secret=" + encode(clientSecret)
+ "&redirect_uri=" + encode(redirectUri)
+ "&grant_type=authorization_code";
var request = transport.createRequestFactory()
.buildPostRequest(tokenRequest,
new com.google.api.client.http.ByteArrayContent(
"application/x-www-form-urlencoded",
body.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
var response = request.execute();
var json = factory.createJsonParser(response.getContent()).parseAndClose(
com.google.api.client.util.GenericData.class);
String idTokenStr = (String) json.get("id_token");
if (idTokenStr == null) {
throw new GoogleOAuthException("No id_token in Google token response");
}
// Verify ID token signature and claims
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, factory)
.setAudience(Collections.singletonList(clientId))
.build();
GoogleIdToken idToken = verifier.verify(idTokenStr);
if (idToken == null) {
throw new GoogleOAuthException("Google ID token verification failed");
}
GoogleIdToken.Payload payload = idToken.getPayload();
if (!Boolean.TRUE.equals(payload.getEmailVerified())) {
throw new GoogleOAuthException("Google account email is not verified");
}
String sub = payload.getSubject();
String email = payload.getEmail();
String name = (String) payload.get("name");
String hd = payload.getHostedDomain(); // null for gmail.com / personal accounts
log.info("Google identity verified: sub={} email={} hd={}", sub, email, hd);
return new GoogleIdentity(sub, email, name, hd);
} catch (GoogleOAuthException e) {
throw e;
} catch (Exception e) {
throw new GoogleOAuthException("Google token exchange failed: " + e.getMessage(), e);
}
}
private static String encode(String value) {
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
}
// Value types
/** Verified Google identity from the OAuth2 id_token. */
public record GoogleIdentity(
String sub, // Google subject ID (stable, use as external key)
String email, // verified email address
String name, // display name
String hd // hosted domain null for gmail.com recorded for audit, not access control
) {}
public static class GoogleOAuthException extends RuntimeException {
public GoogleOAuthException(String msg) { super(msg); }
public GoogleOAuthException(String msg, Throwable cause) { super(msg, cause); }
}
}

View File

@ -0,0 +1,169 @@
package org.prole.authority.enroll;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
/**
* InviteService creates and validates invite tokens against knoe.invitation.
*
* Trust model:
* The OTP delivered to the invite contact (email or phone) is the first and
* only trust anchor. knoe.dev has zero pre-knowledge of the developer's home
* organisation. The Google link step in enrollment is corroboration only.
*/
@Service
public class InviteService {
private static final Logger log = LoggerFactory.getLogger(InviteService.class);
private final JdbcTemplate db;
private final BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder(12);
private final SecureRandom rng = new SecureRandom();
@Value("${knoe.enroll.inviteTtlHours:72}")
private int inviteTtlHours;
@Value("${knoe.enroll.otpTtlMinutes:10}")
private int otpTtlMinutes;
@Value("${knoe.enroll.otpMaxAttempts:3}")
private int otpMaxAttempts;
@Value("${knoe.enroll.baseUrl:https://auth.knoe.dev}")
private String baseUrl;
public InviteService(JdbcTemplate db) {
this.db = db;
}
/**
* Create a new invite and return the enrollment URL + raw OTP.
* The caller is responsible for sending the OTP to the contact.
*
* @param contact email address or phone number
* @param contactType "email" or "sms"
* @param nameHint optional display name hint from admin
* @param createdBy admin username
* @return InviteResult containing enrollUrl and rawOtp to dispatch
*/
public InviteResult createInvite(String contact, String contactType,
String nameHint, String createdBy) {
String token = UUID.randomUUID().toString();
String rawOtp = String.format("%06d", rng.nextInt(1_000_000));
String otpHash = bcrypt.encode(rawOtp);
Instant now = Instant.now();
Instant otpExpiry = now.plusSeconds(otpTtlMinutes * 60L);
Instant invExpiry = now.plusSeconds(inviteTtlHours * 3600L);
db.update("""
INSERT INTO knoe.invitation
(token, contact, contact_type, name_hint, otp_hash,
otp_expires_at, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
token, contact, contactType, nameHint, otpHash,
java.sql.Timestamp.from(otpExpiry), createdBy,
java.sql.Timestamp.from(invExpiry)
);
String enrollUrl = baseUrl + "/auth/enroll?token=" + token;
log.info("Invite created for {} by {} — token={} expires={}",
contact, createdBy, token, invExpiry);
return new InviteResult(token, enrollUrl, rawOtp);
}
/**
* Look up a valid (non-expired, not-used) invite by token.
* Returns null if no matching valid invite exists.
*/
public Map<String, Object> findValidInvite(String token) {
var rows = db.queryForList("""
SELECT * FROM knoe.invitation
WHERE token = ?
AND used_at IS NULL
AND expires_at > now()
""", token);
return rows.isEmpty() ? null : rows.get(0);
}
/**
* Verify the OTP for a given token.
*
* @return OtpResult indicating success, invalid, expired, or locked
*/
public OtpResult verifyOtp(String token, String rawOtp) {
Map<String, Object> inv = findValidInvite(token);
if (inv == null) {
return OtpResult.INVALID_TOKEN;
}
Instant otpExpiry = ((java.sql.Timestamp) inv.get("otp_expires_at")).toInstant();
if (Instant.now().isAfter(otpExpiry)) {
return OtpResult.OTP_EXPIRED;
}
int attempts = (int) inv.get("otp_attempts");
if (attempts >= otpMaxAttempts) {
return OtpResult.LOCKED;
}
String otpHash = (String) inv.get("otp_hash");
if (!bcrypt.matches(rawOtp, otpHash)) {
db.update("UPDATE knoe.invitation SET otp_attempts = otp_attempts + 1 WHERE token = ?",
token);
int remaining = otpMaxAttempts - attempts - 1;
log.warn("OTP mismatch for token={} attempts={} remaining={}", token, attempts + 1, remaining);
return remaining <= 0 ? OtpResult.LOCKED : OtpResult.WRONG_OTP;
}
// Mark OTP as verified
db.update("UPDATE knoe.invitation SET otp_verified_at = now() WHERE token = ?", token);
log.info("OTP verified for token={}", token);
return OtpResult.OK;
}
/**
* Check that the OTP has been verified for this token (gate before steps 2-4).
*/
public boolean isOtpVerified(String token) {
Integer count = db.queryForObject("""
SELECT COUNT(*) FROM knoe.invitation
WHERE token = ? AND otp_verified_at IS NOT NULL
AND used_at IS NULL AND expires_at > now()
""", Integer.class, token);
return count != null && count > 0;
}
/**
* Mark an invite as fully used after successful enrollment.
*/
public void markUsed(String token, String username) {
db.update("""
UPDATE knoe.invitation
SET used_at = now(), used_by = ?
WHERE token = ?
""", username, token);
}
// Value types
public record InviteResult(String token, String enrollUrl, String rawOtp) {}
public enum OtpResult {
OK,
WRONG_OTP,
OTP_EXPIRED,
LOCKED,
INVALID_TOKEN
}
}

View File

@ -0,0 +1,90 @@
package org.prole.authority.enroll;
import dev.samstevens.totp.code.CodeGenerator;
import dev.samstevens.totp.code.CodeVerifier;
import dev.samstevens.totp.code.DefaultCodeGenerator;
import dev.samstevens.totp.code.DefaultCodeVerifier;
import dev.samstevens.totp.code.HashingAlgorithm;
import dev.samstevens.totp.qr.QrData;
import dev.samstevens.totp.secret.DefaultSecretGenerator;
import dev.samstevens.totp.secret.SecretGenerator;
import dev.samstevens.totp.time.SystemTimeProvider;
import dev.samstevens.totp.time.TimeProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* TotpService generates TOTP secrets and QR code URIs (RFC 6238).
*
* Compatible with Google Authenticator, Authy, and any RFC 6238 app.
* The TOTP secret is stored encrypted (AES-GCM via OpenBao) in knoe.totp_credential.
* This service handles generation and verification only encryption is the
* responsibility of UserProvisioningService.
*/
@Service
public class TotpService {
private static final Logger log = LoggerFactory.getLogger(TotpService.class);
private static final int DIGITS = 6;
private static final int PERIOD = 30; // seconds
private static final int WINDOW = 1; // ±1 period tolerance
private static final HashingAlgorithm ALGO = HashingAlgorithm.SHA1; // GA compat
@Value("${knoe.enroll.totpIssuer:Knoe.DEV}")
private String issuer;
private final SecretGenerator secretGen = new DefaultSecretGenerator(32);
private final TimeProvider timeProvider = new SystemTimeProvider();
private final CodeGenerator codeGen = new DefaultCodeGenerator(ALGO, DIGITS);
private final CodeVerifier verifier = new DefaultCodeVerifier(codeGen, timeProvider);
/**
* Generate a new random TOTP secret (Base32-encoded, 32 chars).
* This is the plaintext value encrypt before persisting.
*/
public String generateSecret() {
return secretGen.generate();
}
/**
* Build the otpauth:// URI for rendering as a QR code.
*
* @param username knoe username (label shown in the authenticator app)
* @param secret plaintext Base32 TOTP secret
* @return QR URI string
*/
public String buildQrUri(String username, String secret) {
QrData data = new QrData.Builder()
.label(username)
.secret(secret)
.issuer(issuer)
.algorithm(ALGO)
.digits(DIGITS)
.period(PERIOD)
.build();
return data.getUri();
}
/**
* Verify a 6-digit TOTP code against the secret.
* Accepts ±WINDOW periods to handle minor clock skew.
*
* @param secret plaintext Base32 TOTP secret
* @param code 6-digit code from authenticator app
* @return true if valid
*/
public boolean verify(String secret, String code) {
if (secret == null || code == null) return false;
try {
boolean valid = verifier.isValidCode(secret, code);
log.debug("TOTP verify code={} valid={}", code, valid);
return valid;
} catch (Exception e) {
log.warn("TOTP verification error: {}", e.getMessage());
return false;
}
}
}

View File

@ -0,0 +1,126 @@
package org.prole.authority.enroll;
import org.prole.authority.kerberos.KadminClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
/**
* UserProvisioningService orchestrates enrollment completion.
*
* Called after all three enrollment gates have passed:
* 1. OTP verified (contact ownership proven)
* 2. Google identity linked (corroboration recorded)
* 3. TOTP secret verified (device 2FA confirmed)
*
* Creates the knoe.user row, links identity, stores TOTP credential,
* creates the Kerberos principal, marks the invite used, and queues
* async provisioning jobs (Gitea, GitLab accounts).
*
* NOTE: TOTP secret encryption via OpenBao is deferred to Round 1.5.
* For now the secret is stored as-is this will be replaced with
* AES-GCM envelope encryption using a transit key.
*/
@Service
public class UserProvisioningService {
private static final Logger log = LoggerFactory.getLogger(UserProvisioningService.class);
private final JdbcTemplate db;
private final KadminClient kadmin;
private final InviteService inviteService;
public UserProvisioningService(JdbcTemplate db,
KadminClient kadmin,
InviteService inviteService) {
this.db = db;
this.kadmin = kadmin;
this.inviteService = inviteService;
}
/**
* Complete enrollment for a verified session.
*
* @param inviteToken the invite token (used to mark invite consumed)
* @param username chosen knoe username
* @param displayName display name from PII form
* @param email canonical email (from Google ID token)
* @param googleId verified GoogleIdentity
* @param totpSecret plaintext TOTP secret (verified in enrollment step 3)
*/
@Transactional
public ProvisionResult provision(String inviteToken,
String username,
String displayName,
String email,
GoogleOAuthService.GoogleIdentity googleId,
String totpSecret) {
log.info("Provisioning user: username={} email={} hd={}",
username, email, googleId.hd());
// 1. Insert knoe.user
Integer userId = db.queryForObject("""
INSERT INTO knoe.user (username, display_name, email, realm, created_at)
VALUES (?, ?, ?, 'KNOE.DEV', now())
RETURNING id
""", Integer.class, username, displayName, email);
if (userId == null) {
throw new ProvisioningException("Failed to create user row for " + username);
}
// 2. Insert knoe.identity (Google corroboration)
db.update("""
INSERT INTO knoe.identity
(user_id, provider, provider_sub, provider_email, provider_hd, verified_at)
VALUES (?, 'google', ?, ?, ?, now())
""", userId, googleId.sub(), googleId.email(), googleId.hd());
// 3. Insert knoe.totp_credential
// TODO Round 1.5: encrypt secret with OpenBao transit key before storing.
db.update("""
INSERT INTO knoe.totp_credential (user_id, secret, verified_at, created_at)
VALUES (?, ?, now(), now())
""", userId, totpSecret);
// 4. Create Kerberos principal
try {
kadmin.addPrincipal(username + "@KNOE.DEV");
} catch (Exception e) {
log.error("kadmin addprinc failed for {}: {}", username, e.getMessage());
throw new ProvisioningException("Kerberos principal creation failed: " + e.getMessage(), e);
}
// 5. Mark invite used
inviteService.markUsed(inviteToken, username);
// 6. Queue provisioning jobs
queueJob(userId, "create_gitea_user",
"{\"username\":\"" + username + "\",\"email\":\"" + email + "\"}");
queueJob(userId, "create_gitlab_user",
"{\"username\":\"" + username + "\",\"email\":\"" + email + "\"}");
log.info("Provisioning complete for user {} (id={})", username, userId);
return new ProvisionResult(userId, username, email);
}
private void queueJob(int userId, String jobType, String payloadJson) {
db.update("""
INSERT INTO knoe.provisioning_job (user_id, job_type, status, payload, created_at, updated_at)
VALUES (?, ?, 'pending', ?::jsonb, now(), now())
""", userId, jobType, payloadJson);
}
// Value types
public record ProvisionResult(int userId, String username, String email) {}
public static class ProvisioningException extends RuntimeException {
public ProvisioningException(String msg) { super(msg); }
public ProvisioningException(String msg, Throwable cause) { super(msg, cause); }
}
}

View File

@ -0,0 +1,136 @@
package org.prole.authority.kerberos;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* KadminClient shells out to kadmin.local to manage Kerberos principals.
*
* Runs in the same pod as the KDC sidecar, so kadmin.local has direct database access.
* All operations are idempotent: addPrincipal is a no-op if the principal already exists.
*
* For use in enrollment only not exposed via any HTTP endpoint.
*/
@Component
public class KadminClient {
private static final Logger log = LoggerFactory.getLogger(KadminClient.class);
private static final int TIMEOUT_SECONDS = 30;
@Value("${knoe.kerberos.realm:KNOE.DEV}")
private String realm;
/**
* Create a new principal with a random key (no password Kerberos keytab only).
* For human users this is overridden at first kinit with kadmin changepass.
*
* @param principal fully-qualified principal, e.g. "chrisfu@KNOE.DEV"
*/
public void addPrincipal(String principal) {
String fqPrincipal = qualified(principal);
// Check if it already exists idempotent
try {
runKadmin("get_principal " + fqPrincipal);
log.info("Principal {} already exists — skipping addprinc.", fqPrincipal);
return;
} catch (KadminException e) {
// Principal doesn't exist proceed to create
}
runKadmin("addprinc -randkey " + fqPrincipal);
log.info("Created Kerberos principal: {}", fqPrincipal);
}
/**
* Set the password for a principal (used during password reset flows).
*
* @param principal fully-qualified or bare principal
* @param password new password (will not be logged)
*/
public void changePrincipalPassword(String principal, String password) {
String fqPrincipal = qualified(principal);
runKadmin("cpw -pw " + password + " " + fqPrincipal);
log.info("Password changed for principal: {}", fqPrincipal);
}
/**
* Delete a principal (used when an account is disabled).
*
* @param principal fully-qualified or bare principal
*/
public void deletePrincipal(String principal) {
String fqPrincipal = qualified(principal);
runKadmin("delprinc -force " + fqPrincipal);
log.info("Deleted principal: {}", fqPrincipal);
}
/**
* Export a keytab for a principal to a file path.
*
* @param principal fully-qualified principal
* @param keytabPath absolute path on the local filesystem
*/
public void exportKeytab(String principal, String keytabPath) {
String fqPrincipal = qualified(principal);
runKadmin("ktadd -k " + keytabPath + " " + fqPrincipal);
log.info("Exported keytab for {} to {}", fqPrincipal, keytabPath);
}
// Internal
private String qualified(String principal) {
if (principal.contains("@")) return principal;
return principal + "@" + realm;
}
private void runKadmin(String query) {
List<String> cmd = List.of("kadmin.local", "-q", query);
log.debug("kadmin.local -q \"{}\"", query.startsWith("cpw") ? "cpw -pw *** ..." : query);
try {
Process proc = new ProcessBuilder(cmd)
.redirectErrorStream(true)
.start();
String output;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(proc.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = proc.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
proc.destroyForcibly();
throw new KadminException("kadmin.local timed out after " + TIMEOUT_SECONDS + "s");
}
int exit = proc.exitValue();
if (exit != 0) {
// get_principal returns 1 for "Principal does not exist" caller handles
if (output.contains("Principal does not exist")) {
throw new KadminException("Principal does not exist");
}
throw new KadminException("kadmin.local exited " + exit + ": " + output);
}
} catch (KadminException e) {
throw e;
} catch (Exception e) {
throw new KadminException("kadmin.local execution failed: " + e.getMessage(), e);
}
}
public static class KadminException extends RuntimeException {
public KadminException(String msg) { super(msg); }
public KadminException(String msg, Throwable cause) { super(msg, cause); }
}
}

View File

@ -0,0 +1,191 @@
package org.prole.authority.provisioning;
import org.prole.authority.admin.KnobjectService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Map;
/**
* ProvisioningWorker polls knoe.provisioning_job and dispatches pending jobs.
*
* Supported job types for Round 1:
* create_gitea_user create Gitea account via Gitea API
* create_gitlab_user create GitLab account via GitLab API (stub)
* grant_gitea_access add member to Gitea org/repo
* grant_gitlab_access add member to GitLab group/project (stub)
*
* Runs on a fixed delay to avoid concurrent execution on multi-replica deployments.
* In Round 2 this should use advisory locks or a proper job queue.
*/
@Component
@EnableScheduling
public class ProvisioningWorker {
private static final Logger log = LoggerFactory.getLogger(ProvisioningWorker.class);
private final JdbcTemplate db;
private final KnobjectService knobjectService;
private final RestTemplate http = new RestTemplate();
@Value("${knoe.provisioning.giteaUrl:https://git.knoe.dev}")
private String giteaUrl;
@Value("${knoe.provisioning.giteaToken:}")
private String giteaToken;
public ProvisioningWorker(JdbcTemplate db, KnobjectService knobjectService) {
this.db = db;
this.knobjectService = knobjectService;
}
@Scheduled(fixedDelayString = "${knoe.provisioning.pollIntervalMs:10000}")
public void poll() {
List<Map<String, Object>> jobs = db.queryForList("""
SELECT id, user_id, job_type, payload
FROM knoe.provisioning_job
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
""");
if (jobs.isEmpty()) return;
log.debug("Provisioning: {} pending job(s)", jobs.size());
for (var job : jobs) {
int jobId = (int) job.get("id");
int userId = (int) job.get("user_id");
String jobType = (String) job.get("job_type");
Object payloadObj = job.get("payload");
String payload = payloadObj != null ? payloadObj.toString() : "{}";
markRunning(jobId);
try {
dispatch(jobId, userId, jobType, payload);
markDone(jobId, "{\"status\":\"ok\"}");
log.info("Job {} ({}) completed for userId={}", jobId, jobType, userId);
} catch (Exception e) {
log.error("Job {} ({}) failed for userId={}: {}", jobId, jobType, userId, e.getMessage());
markFailed(jobId, "{\"error\":\"" + escape(e.getMessage()) + "\"}");
}
}
}
private void dispatch(int jobId, int userId, String jobType, String payload) {
switch (jobType) {
case "create_gitea_user" -> createGiteaUser(payload);
case "create_gitlab_user" -> createGitlabUser(payload);
case "grant_gitea_access" -> grantGiteaAccess(payload);
case "grant_gitlab_access"-> grantGitlabAccess(payload);
default -> log.warn("Unknown job type '{}' for jobId={}", jobType, jobId);
}
}
// Gitea
private void createGiteaUser(String payloadJson) {
if (giteaToken == null || giteaToken.isBlank()) {
log.warn("GITEA_TOKEN not set — skipping create_gitea_user");
return;
}
// Parse username + email from JSON payload (simple extraction, no full JSON parser needed)
String username = extractJsonField(payloadJson, "username");
String email = extractJsonField(payloadJson, "email");
String body = """
{"login_name":"%s","username":"%s","email":"%s",
"password":"%s","must_change_password":true,
"send_notify":true,"source_id":0}
""".formatted(username, username, email, generateTempPassword());
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "token " + giteaToken);
var response = http.postForEntity(
giteaUrl + "/api/v1/admin/users",
new HttpEntity<>(body, headers),
String.class);
log.info("Gitea user {} created — HTTP {}", username, response.getStatusCode());
}
private void grantGiteaAccess(String payloadJson) {
if (giteaToken == null || giteaToken.isBlank()) {
log.warn("GITEA_TOKEN not set — skipping grant_gitea_access");
return;
}
// Stub implemented fully in Round 1.5 when repo structure is defined
log.info("grant_gitea_access (stub): {}", payloadJson);
}
// GitLab
private void createGitlabUser(String payloadJson) {
// Stub for Round 1 GitLab is on git.prole.org which may not be accessible
// from knoe-dev-0. Implement via GitLab API in Round 1.5.
log.info("create_gitlab_user (stub — implement in Round 1.5): {}", payloadJson);
}
private void grantGitlabAccess(String payloadJson) {
log.info("grant_gitlab_access (stub — implement in Round 1.5): {}", payloadJson);
}
// Helpers
private void markRunning(int jobId) {
db.update("UPDATE knoe.provisioning_job SET status='running', updated_at=now() WHERE id=?",
jobId);
}
private void markDone(int jobId, String resultJson) {
db.update("""
UPDATE knoe.provisioning_job
SET status='done', result=?::jsonb, updated_at=now()
WHERE id=?
""", resultJson, jobId);
}
private void markFailed(int jobId, String resultJson) {
db.update("""
UPDATE knoe.provisioning_job
SET status='failed', result=?::jsonb, updated_at=now()
WHERE id=?
""", resultJson, jobId);
}
/** Very simple JSON field extraction — avoids adding a JSON dep just for this. */
private static String extractJsonField(String json, String field) {
String marker = "\"" + field + "\":\"";
int start = json.indexOf(marker);
if (start < 0) return "";
start += marker.length();
int end = json.indexOf('"', start);
return end < 0 ? "" : json.substring(start, end);
}
private static String escape(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static String generateTempPassword() {
// 20-char random alphanumeric user must change on first login
var sb = new StringBuilder(20);
var rng = new java.security.SecureRandom();
String chars = "ABCDEFGHJKMNPQRSTWXYZabcdefghjkmnpqrstwxyz23456789@#$!";
for (int i = 0; i < 20; i++) sb.append(chars.charAt(rng.nextInt(chars.length())));
return sb.toString();
}
}

View File

@ -22,6 +22,41 @@ knoe:
adminPrincipals: [] adminPrincipals: []
kerberos: kerberos:
# REQUIRED for SPNEGO when enabled. Provide via env. # REQUIRED for SPNEGO when enabled. Provide via env.
servicePrincipal: "" servicePrincipal: ${KNOE_KERBEROS_SERVICE_PRINCIPAL:}
keytabPath: "" keytabPath: ${KNOE_KERBEROS_KEYTAB_PATH:}
realm: "" realm: ${KNOE_KERBEROS_REALM:}
# ── Enrollment (knoe-auth Round 1) ──────────────────────────────────────
enroll:
inviteTtlHours: ${KNOE_ENROLL_INVITE_TTL_HOURS:72}
otpTtlMinutes: ${KNOE_ENROLL_OTP_TTL_MINUTES:10}
otpMaxAttempts: ${KNOE_ENROLL_OTP_MAX_ATTEMPTS:3}
totpIssuer: ${KNOE_ENROLL_TOTP_ISSUER:Knoe.DEV}
# Base URL used in invite emails and enrollment redirect URIs
baseUrl: ${KNOE_AUTH_BASE_URL:https://auth.knoe.dev}
# ── Google OAuth2 corroboration ──────────────────────────────────────────
# No allowed-domains list — any Google account is accepted.
# Trust is established by invite OTP, not the developer's home domain.
# provider_hd is recorded in knoe.identity for audit only.
google:
clientId: ${GOOGLE_CLIENT_ID:}
clientSecret: ${GOOGLE_CLIENT_SECRET:}
redirectUri: ${KNOE_AUTH_BASE_URL:https://auth.knoe.dev}/auth/enroll/google-callback
# ── Async provisioning worker ─────────────────────────────────────────────
provisioning:
pollIntervalMs: ${KNOE_PROVISIONING_POLL_INTERVAL_MS:10000}
giteaUrl: ${KNOE_GITEA_URL:https://git.knoe.dev}
giteaToken: ${KNOE_GITEA_TOKEN:}
spring:
# ── Database ─────────────────────────────────────────────────────────────
datasource:
url: ${KNOE_DB_URL:jdbc:postgresql://localhost:5432/knoe}
username: ${KNOE_DB_USER:knoe}
password: ${KNOE_DB_PASSWORD:}
driver-class-name: org.postgresql.Driver
sql:
init:
mode: never

View File

@ -1,23 +1,15 @@
{ {
"assignments": { "assignments": {},
"0": "gke-knoe-dev-0-default-pool-02b40136-m9h9",
"1": "gke-knoe-dev-0-default-pool-6fb9e725-8ldx",
"2": "gke-knoe-dev-0-default-pool-d65ac672-9ncf"
},
"cluster_name": "knoe-db", "cluster_name": "knoe-db",
"desired_instances": 3, "desired_instances": 3,
"eligible_nodes": [ "eligible_nodes": [],
"gke-knoe-dev-0-default-pool-02b40136-m9h9",
"gke-knoe-dev-0-default-pool-6fb9e725-8ldx",
"gke-knoe-dev-0-default-pool-d65ac672-9ncf"
],
"metadata": { "metadata": {
"prior_plan_present": true, "prior_plan_present": true,
"reason": "reused", "reason": "new_cluster",
"regenerated": false, "regenerated": true,
"reused": true "reused": false
}, },
"plan_hash": "a44825aeb5ed4e13", "plan_hash": "965d2fde4035059f",
"plan_id": "cnpg-placement-a44825aeb5ed4e13", "plan_id": "cnpg-placement-965d2fde4035059f",
"schema_version": "v1" "schema_version": "v1"
} }

View File

@ -1,5 +1,5 @@
; Knoe Master Configuration File ; Knoe Master Configuration File
; Generated by install.py on 2026-04-14 06:09:00 ; Generated by install.py on 2026-04-22 21:15:00
; This file is used as input for Ansible deployment and k8s cluster creation. ; This file is used as input for Ansible deployment and k8s cluster creation.
[User] [User]
@ -28,37 +28,35 @@ database_options.ext.pgvector = true
database_options.ext.postgis = true database_options.ext.postgis = true
database_options.ext.postgres_fdw = true database_options.ext.postgres_fdw = true
database_options.version_type = v18 database_options.version_type = v18
dependencies.ansible.install = true
dependencies.auto_install_missing = true dependencies.auto_install_missing = true
dependencies.brew.install = true dependencies.brew.install = true
dependencies.containerd.install = true
dependencies.docker-buildx.install = true
dependencies.docker.install = true dependencies.docker.install = true
dependencies.gcloud.install = true dependencies.gcloud.install = true
dependencies.k3d.install = true dependencies.k3d.install = true
dependencies.kubectl.install = true dependencies.kubectl.install = true
dependencies.kubectx.install = true dependencies.kubectx.install = true
dependencies.op.install = true
dependencies.opentofu.install = true dependencies.opentofu.install = true
dependencies.python.install = true dependencies.python.install = true
dependencies.verify_all = false dependencies.verify_all = false
disk_selection.disk_type = local disk_selection.disk_type = local
disk_selection.local_path = /Users/chrisfu/dev/knoe/knoe-tools-app/dist disk_selection.local_path = /Users/chrisfu/dev/knoe-db/knoe-tools-app/dist
disk_selection.removable_mount = disk_selection.removable_mount =
env_setup.CLUSTER_NAME = ${CLUSTER_NAME} env_setup.CLUSTER_NAME = ${CLUSTER_NAME}
env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE} env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE}
env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe/conf env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe-db/conf
env_setup.PROLE_DATA = /Users/chrisfu/dev/knoe/data
env_setup.KNOE_HOME = /Users/chrisfu/dev/knoe
env_setup.PROLE_LOGS = /Users/chrisfu/dev/knoe/logs
env_setup.KNOE_SERVICE = /Users/chrisfu/dev/knoe/etc
gitops.git_provider = Gitea gitops.git_provider = Gitea
gitops.node_selector = gitops.node_selector =
init_cluster.argocd_enabled = false init_cluster.argocd_enabled = false
init_cluster.at_rest_encryption_enabled = true init_cluster.at_rest_encryption_enabled = true
init_cluster.cluster_env = dev init_cluster.cluster_env = dev
init_cluster.deployment_target = knoe-dev-cluster init_cluster.deployment_target = knoe-dev-cluster
init_cluster.gitops_enabled = true init_cluster.gitops_enabled = false
init_cluster.k3s_server_url = https://myrddin.knoe.org:6443 init_cluster.k3s_server_url = https://myrddin.prole.org:6443
init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
init_cluster.kerberos_enabled = true init_cluster.kerberos_enabled = false
init_cluster.mode = k3d init_cluster.mode = k3d
init_cluster.start_cluster = true init_cluster.start_cluster = true
init_cluster.supabase_enabled = false init_cluster.supabase_enabled = false
@ -73,10 +71,10 @@ init_password.db_password_confirm =
init_password.db_username = root init_password.db_username = root
init_password.generate_ssh_key = true init_password.generate_ssh_key = true
init_scripts.run_scripts = true init_scripts.run_scripts = true
kerberos_config.enabled = true kerberos_config.enabled = false
kerberos_config.kdc = 10.0.0.3 kerberos_config.kdc =
kerberos_config.password = kerberos_config.password =
kerberos_config.realm = PROLE.ORG kerberos_config.realm =
kerberos_config.test_connection = false kerberos_config.test_connection = false
kerberos_config.user = administrator kerberos_config.user = administrator
network_scan.run = true network_scan.run = true
@ -84,132 +82,87 @@ ollama_config.model =
ollama_config.server_host = ollama_config.server_host =
ollama_config.server_port = 11434 ollama_config.server_port = 11434
supabase_config.pv_base_dir = /synology/d005 supabase_config.pv_base_dir = /synology/d005
supabase_config.pv_node = gandalf.knoe.org supabase_config.pv_node =
[Global] [Global]
; Variables used by name in more than one place or assumed global scope ; Variables used by name in more than one place or assumed global scope
ARGOCD_NAMESPACE = argocd
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
AUTHORITY_ENABLED = true
AUTH_HOSTNAME = api.knoe.org
AUTH_LOGIN_PATH = /auth/login
AUTH_RESPONSE_HEADERS = X-Knoe-User,X-Knoe-Email,X-Knoe-Groups
AUTH_VERIFY_PATH = /auth/verify
CLUSTER_ENV = dev CLUSTER_ENV = dev
CLUSTER_NAME = knoe-db CLUSTER_NAME = knoe-db
CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json DATABASE_NAMESPACE = knoe-db-18-140
CNPG_PLACEMENT_PLAN_HASH = 965d2fde4035059f
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-965d2fde4035059f
DATABASE_NAMESPACE = knoe-db-18-008-18-009-18-013-18-014
DB_HOST_PORT = 5432 DB_HOST_PORT = 5432
DEPLOYMENT_MODE = k3d DEPLOYMENT_MODE = k3d
DEPLOYMENT_TARGET = knoe-dev-cluster DEPLOYMENT_TARGET = knoe-dev-cluster
DOCKER_PRELOAD = false DOCKER_PRELOAD = false
GITLAB_PUBLIC_HOSTS = git.knoe.dev,git.knoe.org
K3S_SERVER = https://myrddin.knoe.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:CWWf3RHFdbUrmfrY:It8a2G8QUUIsqVwMsm3LsI4UvSSChEc_uAdESwzYplZLOCiSsCbOKuT9FbPpIwQvEaG_gLz9ZAfkD0EQxJp81KAtpk_X3K_nxVUa0RPRlbt_wdeXXoMoFFpN5BqXXz2HZwKgh_gpK1hjVbsJQHKAbTqWfu8u_LTmYYg4ag==}
KNOE_DB_USER = root KNOE_DB_USER = root
KUBECONTEXT = dev
MONITORING_STORAGE_CLASS = local-path
OPENTOFU_URL = http://127.0.0.1:8080
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
PROLE_K3S_SERVER = https://myrddin.knoe.org:6443 PROLE_K3S_SERVER = https://myrddin.prole.org:6443
PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
PROLE_OPENTOFU_URL = http://127.0.0.1:8080 PROLE_OPENTOFU_URL = http://127.0.0.1:8080
PROTECTED_DB_HOSTS = db.0.knoe.dev,db.knoe.org
PROTECTED_GIT_HOSTS = git.knoe.dev,git.knoe.org
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system SERVICE_NAMESPACE = knoe-system
SUPABASE_PV_BASE = /synology/d005 SUPABASE_PV_BASE = /synology/d005
SUPABASE_PV_BASE_DIR = /synology/d005 SUPABASE_PV_BASE_DIR = /synology/d005
SUPABASE_PV_NODE = gandalf.knoe.org
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev,db.knoe.org
[Welcome] [Welcome]
; No configuration values captured yet for this section. ; No configuration values captured yet for this section.
[Dependencies] [Dependencies]
STATUS = All installed ; No configuration values captured yet for this section.
[Network] [Network]
AD_DC_HOST = myrddin.knoe.org KERBEROS_AUTO_ENABLED = False
AD_DC_IP = 10.0.0.3
ANSIBLE_DOMAIN = knoe.org
ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure
ANSIBLE_INVENTORY = $HOME/dev/knoe/infrastructure/inventory
ANSIBLE_REALM = PROLE.ORG
ANSIBLE_TOPOLOGY = {"domain":"knoe.org","realm":"PROLE.ORG","internal_records":{"aventage.knoe.org":"10.0.0.206","fairyland.knoe.org":"10.0.0.208","loghost.knoe.org":"10.0.0.3","merlin.knoe.org":"10.0.0.6","morana.knoe.org":"10.0.0.66","morgoth.knoe.org":"10.0.0.204","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207","synology.knoe.org":"10.0.0.203","zinfandel.knoe.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.knoe.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.knoe.org:6443","server_host":"myrddin.knoe.org","token_present":true},"groups":{"iscsi":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"pihole":["pi.knoe.org","raspberry.knoe.org"],"ad_dc":["myrddin.knoe.org"],"k3s_servers":["myrddin.knoe.org"],"k3s_agents":["merlin.knoe.org","gandalf.knoe.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"ssl_hosts":["myrddin.knoe.org"],"mariadb_primary":["merlin.knoe.org"],"mariadb_replica":["raspberry.knoe.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"],"k3s_hosts":["gandalf.knoe.org"]},"hosts":{"merlin":"10.0.0.6","merlin.knoe.org":"10.0.0.6","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207"},"unmapped_hosts":["gandalf.knoe.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]}
KDC_ANSIBLE_DETECTED = 10.0.0.3
KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True
[Port Forwards] [Port Forwards]
; No configuration values captured yet for this section. ; No configuration values captured yet for this section.
[System Environment] [System Environment]
KNOE_CONF = $HOME/dev/knoe/conf ; No configuration values captured yet for this section.
PROLE_DATA = $HOME/dev/knoe/data
KNOE_HOME = $HOME/dev/knoe
PROLE_LOGS = $HOME/dev/knoe/logs
KNOE_SERVICE = $HOME/dev/knoe/etc
[Monitoring] [Monitoring]
MONITORING_STORAGE_CLASS = local-path ; No configuration values captured yet for this section.
[Kerberos Authentication] [Kerberos Authentication]
; No configuration values captured yet for this section. ; No configuration values captured yet for this section.
[Ollama] [Ollama]
OLLAMA_SERVERS = 10.0.0.208:11434,fairyland.knoe.org:11434,k3d.localhost:11434,morgoth.knoe.org:11434 ; No configuration values captured yet for this section.
[Optional Features] [Optional Features]
AT_REST_ENCRYPTION_ENABLED = true ; No configuration values captured yet for this section.
GITOPS_ENABLED = True
GITOPS_PROVIDER = GitLab
KERBEROS_ENABLED = true
SUPABASE_ENABLED = false
[GitOps] [GitOps]
; No configuration values captured yet for this section. ; No configuration values captured yet for this section.
[Database Creation] [Database Creation]
APP_CLUSTER_NAME = knoe-dev-0 ; No configuration values captured yet for this section.
DB_CLUSTER_NAME = knoe-cnpg-0
DB_USER = root
[Initialize Cluster] [Initialize Cluster]
ENVIRONMENT = dev ; No configuration values captured yet for this section.
K3S_SERVER_URL = https://myrddin.knoe.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:ozzcomisjsQYIkSH:Ytp91WR_iP4tJyTAmdH_SRhcKycgzea0zLAgTBNxDsQaBPM-pR_VK3u9wc5QkFzszdAHZGBhVN2HKyqnz-cqDR0WAus88DFbF4zWlgvl6gKEAynaXbdMwAa6vYLUGi8ZE0u1pRiO4KJyiulhBIpfoMReM1Wu6Mj1-20hXw==}
[Dev Cluster (k3d)] [Dev Cluster (k3d)]
CLUSTER_ENV = dev CLUSTER_ENV = dev
DISPLAY_NAME = knoe-dev-cluster DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = dev
MODE = k3d MODE = k3d
[Service Cluster (k3s)] [Service Cluster (k3s)]
CLUSTER_ENV = knoe-service-cluster CLUSTER_ENV = knoe-service-cluster
DISPLAY_NAME = knoe-service-cluster DISPLAY_NAME = knoe-service-cluster
K3S_SERVER_URL = https://myrddin.knoe.org:6443 K3S_SERVER_URL = https://myrddin.prole.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
MODE = k3s MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080
[GCP] [GCP]
BILLING_ACCOUNT = 01193C-25783B-3211AD ; No configuration values captured yet for this section.
BILLING_PROJECT = plenary-truck-485623-p7
ORG_ID = 584001916389
PROJECT_ID = plenary-truck-485623-p7
[Prod Cluster (k8s)] [Prod Cluster (k8s)]
ARTIFACTS_DIR = $HOME/dev/knoe/data/staging ARTIFACTS_DIR = $HOME/dev/knoe-db/data/staging
CLUSTER_ENV = knoe-prod-cluster CLUSTER_ENV = knoe-prod-cluster
DISPLAY_NAME = knoe-prod-cluster DISPLAY_NAME = knoe-prod-cluster
MODE = k8s MODE = k8s
PIPELINE_URL = http://127.0.0.1:8080
[Docker Build] [Docker Build]
LOCAL_REGISTRY = localhost:5000 ; No configuration values captured yet for this section.
LOCAL_REGISTRY_INTERNAL = k3d-knoe-registry.localhost:5000
[Initialization Scripts] [Initialization Scripts]
; No configuration values captured yet for this section. ; No configuration values captured yet for this section.
@ -219,4 +172,4 @@ MODE = k3d
TARGET = knoe-dev-cluster TARGET = knoe-dev-cluster
[Install] [Install]
STATUS = Failed ; No configuration values captured yet for this section.

View File

@ -76,7 +76,7 @@ init_cluster.db_cluster_region =
init_cluster.db_cluster_zones = init_cluster.db_cluster_zones =
init_cluster.deployment_target = knoe-service-cluster init_cluster.deployment_target = knoe-service-cluster
init_cluster.gitops_enabled = true init_cluster.gitops_enabled = true
init_cluster.k3s_server_url = https://myrddin.knoe.org:6443 init_cluster.k3s_server_url = https://myrddin.prole.org:6443
init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
init_cluster.kerberos_enabled = true init_cluster.kerberos_enabled = true
init_cluster.mode = k3s init_cluster.mode = k3s
@ -87,7 +87,7 @@ init_cluster.supabase_enabled = false
init_cluster.supabase_meta_enabled = true init_cluster.supabase_meta_enabled = true
init_cluster.supabase_realtime_enabled = true init_cluster.supabase_realtime_enabled = true
init_cluster.supabase_studio_enabled = false init_cluster.supabase_studio_enabled = false
init_cluster.supabase_studio_url = db.0.knoe.dev init_cluster.supabase_studio_url = db.0.prole.org
init_cnpg_deploy.force_rollout = false init_cnpg_deploy.force_rollout = false
init_cnpg_deploy.run_deploy = true init_cnpg_deploy.run_deploy = true
init_db_build.run_build = true init_db_build.run_build = true
@ -113,24 +113,24 @@ ollama_config.model =
ollama_config.server_host = ollama_config.server_host =
ollama_config.server_port = 11434 ollama_config.server_port = 11434
supabase_config.pv_base_dir = /synology/d005 supabase_config.pv_base_dir = /synology/d005
supabase_config.pv_node = gandalf.knoe.org supabase_config.pv_node = gandalf.prole.org
[Global] [Global]
; Variables used by name in more than one place or assumed global scope ; Variables used by name in more than one place or assumed global scope
ARGOCD_NAMESPACE = argocd ARGOCD_NAMESPACE = argocd
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
AUTHORITY_ENABLED = true AUTHORITY_ENABLED = true
AUTH_HOSTNAME = api.knoe.org AUTH_HOSTNAME = api.prole.org
AUTH_LOGIN_PATH = /auth/login AUTH_LOGIN_PATH = /auth/login
AUTH_RESPONSE_HEADERS = X-Knoe-User,X-Knoe-Email,X-Knoe-Groups AUTH_RESPONSE_HEADERS = X-Knoe-User,X-Knoe-Email,X-Knoe-Groups
AUTH_VERIFY_PATH = /auth/verify AUTH_VERIFY_PATH = /auth/verify
CLUSTER_ENV = service CLUSTER_ENV = service
CLUSTER_NAME = knoe-db CLUSTER_NAME = knoe-db
CNPG_ELIGIBLE_NODES = gandalf.knoe.org,merlin.knoe.org,myrddin.knoe.org CNPG_ELIGIBLE_NODES = gandalf.prole.org,merlin.prole.org,myrddin.prole.org
CNPG_PLACEMENT_PLAN_FILE = $HOME/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json CNPG_PLACEMENT_PLAN_FILE = $HOME/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json
CNPG_PLACEMENT_PLAN_HASH = 962fb2e7bfd2a48b CNPG_PLACEMENT_PLAN_HASH = 962fb2e7bfd2a48b
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-962fb2e7bfd2a48b CNPG_PLACEMENT_PLAN_ID = cnpg-placement-962fb2e7bfd2a48b
CNPG_STAGE1_NODE = gandalf.knoe.org CNPG_STAGE1_NODE = gandalf.prole.org
DATABASE_NAMESPACE = knoe-db DATABASE_NAMESPACE = knoe-db
DB_HOST_PORT = 5432 DB_HOST_PORT = 5432
DB_PASSWORD = ${KNOE_SECRET:v1:Vc5Sow_MQksbOtOJ:bvD1ABxenFlo0304dhf0Me_nzBX0SvLJ7oFvE_TkHGqc0YF8} DB_PASSWORD = ${KNOE_SECRET:v1:Vc5Sow_MQksbOtOJ:bvD1ABxenFlo0304dhf0Me_nzBX0SvLJ7oFvE_TkHGqc0YF8}
@ -138,30 +138,30 @@ DEPLOYMENT_MODE = k3s
DEPLOYMENT_TARGET = knoe-service-cluster DEPLOYMENT_TARGET = knoe-service-cluster
DOCKER_IMPORT_DIR = DOCKER_IMPORT_DIR =
DOCKER_PRELOAD = false DOCKER_PRELOAD = false
GITEA_HOSTNAME = git-internal.knoe.org GITEA_HOSTNAME = git-internal.prole.org
GITLAB_PUBLIC_HOSTS = git.knoe.org GITLAB_PUBLIC_HOSTS = git.prole.org
GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1 GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1
K3S_SERVER = https://myrddin.knoe.org:6443 K3S_SERVER = https://myrddin.prole.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:CWWf3RHFdbUrmfrY:It8a2G8QUUIsqVwMsm3LsI4UvSSChEc_uAdESwzYplZLOCiSsCbOKuT9FbPpIwQvEaG_gLz9ZAfkD0EQxJp81KAtpk_X3K_nxVUa0RPRlbt_wdeXXoMoFFpN5BqXXz2HZwKgh_gpK1hjVbsJQHKAbTqWfu8u_LTmYYg4ag==} K3S_TOKEN = ${KNOE_SECRET:v1:CWWf3RHFdbUrmfrY:It8a2G8QUUIsqVwMsm3LsI4UvSSChEc_uAdESwzYplZLOCiSsCbOKuT9FbPpIwQvEaG_gLz9ZAfkD0EQxJp81KAtpk_X3K_nxVUa0RPRlbt_wdeXXoMoFFpN5BqXXz2HZwKgh_gpK1hjVbsJQHKAbTqWfu8u_LTmYYg4ag==}
KNOE_DB_USER = root KNOE_DB_USER = root
KNOE_IMAGE_REGISTRY = registry.knoe.org KNOE_IMAGE_REGISTRY = registry.prole.org
MONITORING_STORAGE_CLASS = local-path MONITORING_STORAGE_CLASS = local-path
OPENTOFU_URL = http://127.0.0.1:8080 OPENTOFU_URL = http://127.0.0.1:8080
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
KNOE_HOME = $HOME/dev/knoe KNOE_HOME = $HOME/dev/knoe
PROLE_K3S_SERVER = https://myrddin.knoe.org:6443 PROLE_K3S_SERVER = https://myrddin.prole.org:6443
PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
PROTECTED_DB_HOSTS = db.knoe.org PROTECTED_DB_HOSTS = db.prole.org
PROTECTED_GIT_HOSTS = git.knoe.org PROTECTED_GIT_HOSTS = git.prole.org
REDIS_HOST = redis-master.knoe-system.svc.cluster.local REDIS_HOST = redis-master.knoe-system.svc.cluster.local
REGISTRY_NAMESPACE = knoe-system REGISTRY_NAMESPACE = knoe-system
SERVICE_HOSTNAME = svc.knoe.org SERVICE_HOSTNAME = svc.prole.org
SERVICE_NAMESPACE = knoe-system SERVICE_NAMESPACE = knoe-system
SUPABASE_API_HOSTNAME = supabase.knoe.org SUPABASE_API_HOSTNAME = supabase.prole.org
SUPABASE_PV_BASE = /synology/d005 SUPABASE_PV_BASE = /synology/d005
SUPABASE_PV_BASE_DIR = /synology/d005 SUPABASE_PV_BASE_DIR = /synology/d005
SUPABASE_PV_NODE = gandalf.knoe.org SUPABASE_PV_NODE = gandalf.prole.org
SUPABASE_STUDIO_HOSTNAME = db.knoe.org SUPABASE_STUDIO_HOSTNAME = db.prole.org
SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d004,/synology/d005 SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d004,/synology/d005
[Welcome] [Welcome]
@ -171,13 +171,13 @@ SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d004,/synology/d005
STATUS = All installed STATUS = All installed
[Network] [Network]
AD_DC_HOST = myrddin.knoe.org AD_DC_HOST = myrddin.prole.org
AD_DC_IP = 10.0.0.3 AD_DC_IP = 10.0.0.3
ANSIBLE_DOMAIN = knoe.org ANSIBLE_DOMAIN = prole.org
ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure
ANSIBLE_INVENTORY = $HOME/dev/knoe/infrastructure/inventory ANSIBLE_INVENTORY = $HOME/dev/knoe/infrastructure/inventory
ANSIBLE_REALM = PROLE.ORG ANSIBLE_REALM = PROLE.ORG
ANSIBLE_TOPOLOGY = {"domain":"knoe.org","realm":"PROLE.ORG","internal_records":{"aventage.knoe.org":"10.0.0.206","fairyland.knoe.org":"10.0.0.208","loghost.knoe.org":"10.0.0.3","merlin.knoe.org":"10.0.0.6","morana.knoe.org":"10.0.0.66","morgoth.knoe.org":"10.0.0.204","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207","synology.knoe.org":"10.0.0.203","zinfandel.knoe.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.knoe.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.knoe.org:6443","server_host":"myrddin.knoe.org","token_present":true},"groups":{"iscsi":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"pihole":["pi.knoe.org","raspberry.knoe.org"],"ad_dc":["myrddin.knoe.org"],"k3s_servers":["myrddin.knoe.org"],"k3s_agents":["merlin.knoe.org","gandalf.knoe.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"ssl_hosts":["myrddin.knoe.org"],"mariadb_primary":["merlin.knoe.org"],"mariadb_replica":["raspberry.knoe.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"],"k3s_hosts":["gandalf.knoe.org"]},"hosts":{"merlin":"10.0.0.6","merlin.knoe.org":"10.0.0.6","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207"},"unmapped_hosts":["gandalf.knoe.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]} ANSIBLE_TOPOLOGY = {"domain":"prole.org","realm":"PROLE.ORG","internal_records":{"aventage.prole.org":"10.0.0.206","fairyland.prole.org":"10.0.0.208","loghost.prole.org":"10.0.0.3","merlin.prole.org":"10.0.0.6","morana.prole.org":"10.0.0.66","morgoth.prole.org":"10.0.0.204","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207","synology.prole.org":"10.0.0.203","zinfandel.prole.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.prole.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.prole.org:6443","server_host":"myrddin.prole.org","token_present":true},"groups":{"iscsi":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org","gandalf.prole.org"],"pihole":["pi.prole.org","raspberry.prole.org"],"ad_dc":["myrddin.prole.org"],"k3s_servers":["myrddin.prole.org"],"k3s_agents":["merlin.prole.org","gandalf.prole.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org","gandalf.prole.org"],"ssl_hosts":["myrddin.prole.org"],"mariadb_primary":["merlin.prole.org"],"mariadb_replica":["raspberry.prole.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"],"k3s_hosts":["gandalf.prole.org"]},"hosts":{"merlin":"10.0.0.6","merlin.prole.org":"10.0.0.6","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207"},"unmapped_hosts":["gandalf.prole.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]}
KDC_ANSIBLE_DETECTED = 10.0.0.3 KDC_ANSIBLE_DETECTED = 10.0.0.3
KDC_AUTO_DETECTED = 10.0.0.3 KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True KERBEROS_AUTO_ENABLED = True
@ -226,7 +226,7 @@ DB_USER = root
[Initialize Cluster] [Initialize Cluster]
ENVIRONMENT = service ENVIRONMENT = service
K3S_SERVER_URL = https://myrddin.knoe.org:6443 K3S_SERVER_URL = https://myrddin.prole.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
[Dev Cluster (k3d)] [Dev Cluster (k3d)]
@ -238,7 +238,7 @@ MODE = k3d
[Service Cluster (k3s)] [Service Cluster (k3s)]
CLUSTER_ENV = knoe-service-cluster CLUSTER_ENV = knoe-service-cluster
DISPLAY_NAME = knoe-service-cluster DISPLAY_NAME = knoe-service-cluster
K3S_SERVER_URL = https://myrddin.knoe.org:6443 K3S_SERVER_URL = https://myrddin.prole.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
KUBECTL_CONTEXT = knoe-service-cluster KUBECTL_CONTEXT = knoe-service-cluster
MODE = k3s MODE = k3s

View File

@ -132,7 +132,7 @@ def get_runtime_config(path):
gitlab_public_hosts_raw = _cfg_first(g, e, i, "GITLAB_PUBLIC_HOSTS", "gitlab_public_hosts") gitlab_public_hosts_raw = _cfg_first(g, e, i, "GITLAB_PUBLIC_HOSTS", "gitlab_public_hosts")
gitlab_domain = _cfg_first(g, e, i, "GITLAB_DOMAIN", "GITLAB_HOSTNAME", "gitlab_domain", "gitlab_hostname") gitlab_domain = _cfg_first(g, e, i, "GITLAB_DOMAIN", "GITLAB_HOSTNAME", "gitlab_domain", "gitlab_hostname")
if not gitlab_domain: if not gitlab_domain:
gitlab_domain = "git.knoe.dev" if mode == "k8s" else "git.knoe.org" gitlab_domain = "git.knoe.dev" if mode == "k8s" else "git.prole.org"
gitlab_public_hosts = [h.strip() for h in str(gitlab_public_hosts_raw or "").split(",") if h.strip()] gitlab_public_hosts = [h.strip() for h in str(gitlab_public_hosts_raw or "").split(",") if h.strip()]
if not gitlab_public_hosts: if not gitlab_public_hosts:
gitlab_public_hosts = [gitlab_domain] gitlab_public_hosts = [gitlab_domain]
@ -145,11 +145,11 @@ def get_runtime_config(path):
auth_host = _cfg_first(g, e, i, "AUTH_HOSTNAME", "auth_hostname") auth_host = _cfg_first(g, e, i, "AUTH_HOSTNAME", "auth_hostname")
if not auth_host: if not auth_host:
auth_host = "api.knoe.dev" if mode == "k8s" else "api.knoe.org" auth_host = "api.knoe.dev" if mode == "k8s" else "api.prole.org"
service_host = _cfg_first(g, e, i, "SERVICE_HOSTNAME", "service_hostname", "GRAFANA_HOSTNAME", "grafana_hostname") service_host = _cfg_first(g, e, i, "SERVICE_HOSTNAME", "service_hostname", "GRAFANA_HOSTNAME", "grafana_hostname")
if not service_host: if not service_host:
service_host = "svc.knoe.dev" if mode == "k8s" else "svc.knoe.org" service_host = "svc.knoe.dev" if mode == "k8s" else "svc.prole.org"
service_ns = _cfg_first(g, e, i, "SERVICE_NAMESPACE", "service_namespace") or "knoe-system" service_ns = _cfg_first(g, e, i, "SERVICE_NAMESPACE", "service_namespace") or "knoe-system"

View File

@ -0,0 +1,307 @@
---
# knoe-auth-deployment.yaml
# Deploys the knoe-auth pod (Spring Boot + KDC sidecar) to knoe-dev-0 / knoe-system.
# Based on deploy/opentofu/k3s/manifests/knoe/prole-auth-deployment.yaml.
# Realm: KNOE.DEV (not PROLE.LOCAL)
# Image: pulled from Artifact Registry — built by knoe-auth CI pipeline.
#
# Apply: kubectl -n knoe-system apply -f deploy/gcp/gke/knoe-auth-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: knoe-auth
namespace: knoe-system
labels:
app: knoe-auth
spec:
replicas: 1
selector:
matchLabels:
app: knoe-auth
template:
metadata:
labels:
app: knoe-auth
spec:
initContainers:
# keytab-bootstrap: runs kadmin.local inside the shared KDC data volume
# to create the HTTP service principal and export the keytab before the
# Spring Boot container starts.
- name: keytab-bootstrap
image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-authority:latest
imagePullPolicy: Always
command:
- /bin/bash
- -lc
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
realm="${KNOE_KDC_REALM:-KNOE.DEV}"
admin_principal="${KNOE_KDC_ADMIN_PRINCIPAL:-admin/admin}"
if [[ "${admin_principal}" != *"@"* ]]; then
admin_principal="${admin_principal}@${realm}"
fi
svc_principal="${KNOE_KERBEROS_SERVICE_PRINCIPAL:?Missing KNOE_KERBEROS_SERVICE_PRINCIPAL}"
if [[ "${svc_principal}" != *"@"* ]]; then
svc_principal="${svc_principal}@${realm}"
fi
keytab_out="/etc/knoe/keytabs/http.keytab"
mkdir -p "$(dirname "${keytab_out}")"
# If a pre-provisioned keytab is provided as a Secret, use it directly.
if [[ -f /mnt/keytab-secret/http.keytab ]]; then
cp /mnt/keytab-secret/http.keytab "${keytab_out}"
chmod 0400 "${keytab_out}" || true
exit 0
fi
if ! command -v kadmin.local >/dev/null 2>&1; then
echo "Installing Kerberos packages..."
echo "krb5-config krb5-config/default_realm string ${realm}" | debconf-set-selections || true
echo "krb5-config krb5-config/kerberos_servers string 127.0.0.1" | debconf-set-selections || true
echo "krb5-config krb5-config/admin_server string 127.0.0.1" | debconf-set-selections || true
apt-get update -qq
apt-get install -y --no-install-recommends krb5-kdc krb5-admin-server krb5-user dnsutils ca-certificates
rm -rf /var/lib/apt/lists/*
fi
mkdir -p /etc/krb5kdc /var/lib/krb5kdc
[[ -f /opt/knoe-kdc/krb5.conf ]] && cp /opt/knoe-kdc/krb5.conf /etc/krb5.conf
[[ -f /opt/knoe-kdc/kdc.conf ]] && cp /opt/knoe-kdc/kdc.conf /etc/krb5kdc/kdc.conf
[[ -f /opt/knoe-kdc/kadm5.acl ]] && cp /opt/knoe-kdc/kadm5.acl /etc/krb5kdc/kadm5.acl
if [[ -z "${KNOE_KDC_MASTER_PASSWORD:-}" ]]; then
echo "ERROR: Missing KNOE_KDC_MASTER_PASSWORD" >&2; exit 1
fi
if [[ -z "${KNOE_KDC_ADMIN_PASSWORD:-}" ]]; then
echo "ERROR: Missing KNOE_KDC_ADMIN_PASSWORD" >&2; exit 1
fi
if [[ ! -f /var/lib/krb5kdc/principal ]]; then
echo "Initializing realm database for ${realm}..."
kdb5_util create -s -r "${realm}" -P "${KNOE_KDC_MASTER_PASSWORD}"
fi
if ! kadmin.local -q "get_principal ${admin_principal}" >/dev/null 2>&1; then
kadmin.local -q "addprinc -pw ${KNOE_KDC_ADMIN_PASSWORD} ${admin_principal}"
fi
if ! kadmin.local -q "get_principal ${svc_principal}" >/dev/null 2>&1; then
echo "Creating service principal ${svc_principal}..."
kadmin.local -q "addprinc -randkey ${svc_principal}"
fi
kadmin.local -q "ktadd -k ${keytab_out} -norandkey ${svc_principal}"
chmod 0400 "${keytab_out}" || true
echo "Keytab written to ${keytab_out}"
env:
- name: KNOE_KDC_REALM
value: "KNOE.DEV"
- name: KNOE_KDC_ADMIN_PRINCIPAL
value: "admin/admin"
- name: KNOE_KDC_MASTER_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-kdc-secrets
key: master_password
- name: KNOE_KDC_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-kdc-secrets
key: admin_password
- name: KNOE_KERBEROS_SERVICE_PRINCIPAL
value: "HTTP/auth.knoe.dev"
volumeMounts:
- name: keytab
mountPath: /etc/knoe/keytabs
- name: keytab-secret
mountPath: /mnt/keytab-secret
readOnly: true
- name: knoe-kdc-config
mountPath: /opt/knoe-kdc
- name: knoe-kdc-data
mountPath: /var/lib/krb5kdc
- name: knoe-kdc-etc
mountPath: /etc/krb5kdc
containers:
# ── Spring Boot / knoe-auth ──────────────────────────────────────────
- name: knoe-auth
image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-authority:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 20
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 60
periodSeconds: 30
env:
- name: KNOE_AUTH_COOKIE_DOMAIN
value: ".knoe.dev"
- name: KNOE_AUTH_SESSION_SECRET
valueFrom:
secretKeyRef:
name: knoe-auth-secrets
key: sessionSecret
# ── Kerberos ───────────────────────────────────────────────────
- name: KNOE_KERBEROS_SERVICE_PRINCIPAL
value: "HTTP/auth.knoe.dev@KNOE.DEV"
- name: KNOE_KERBEROS_KEYTAB_PATH
value: "/etc/knoe/keytabs/http.keytab"
- name: KNOE_KERBEROS_REALM
value: "KNOE.DEV"
# ── Google OAuth2 (enrollment corroboration) ───────────────────
- name: GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: knoe-auth-google-oidc
key: client_id
optional: true
- name: GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: knoe-auth-google-oidc
key: client_secret
optional: true
# ── Database (CNPG via cluster-internal service) ───────────────
- name: KNOE_DB_URL
value: "jdbc:postgresql://knoe-db-rw.knoe-db-0.svc.cluster.local:5432/knoe"
- name: KNOE_DB_USER
value: "knoe"
- name: KNOE_DB_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-db-app
key: password
optional: true
# ── Enrollment ────────────────────────────────────────────────
- name: KNOE_ENROLL_INVITE_TTL_HOURS
value: "72"
- name: KNOE_ENROLL_OTP_TTL_MINUTES
value: "10"
- name: KNOE_ENROLL_OTP_MAX_ATTEMPTS
value: "3"
- name: KNOE_ENROLL_TOTP_ISSUER
value: "Knoe.DEV"
- name: KNOE_AUTH_BASE_URL
value: "https://auth.knoe.dev"
# ── Provisioning ─────────────────────────────────────────────
- name: KNOE_PROVISIONING_POLL_INTERVAL_MS
value: "10000"
- name: KNOE_GITEA_URL
value: "https://git.knoe.dev"
- name: KNOE_GITEA_TOKEN
valueFrom:
secretKeyRef:
name: knoe-gitea-admin
key: token
optional: true
volumeMounts:
- name: keytab
mountPath: /etc/knoe/keytabs
readOnly: true
- name: knoe-kdc-config
mountPath: /etc/krb5.conf
subPath: krb5.conf
readOnly: true
# ── MIT Kerberos KDC sidecar ─────────────────────────────────────────
- name: kdc
image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-authority:latest
imagePullPolicy: Always
command: ["/bin/bash", "/opt/knoe-kdc/entrypoint.sh"]
env:
- name: KNOE_KDC_REALM
value: "KNOE.DEV"
- name: KNOE_KDC_ADMIN_PRINCIPAL
value: "admin/admin"
- name: KNOE_KDC_MASTER_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-kdc-secrets
key: master_password
- name: KNOE_KDC_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-kdc-secrets
key: admin_password
ports:
- name: krb5-udp
containerPort: 88
protocol: UDP
- name: krb5-tcp
containerPort: 88
protocol: TCP
- name: kpasswd-udp
containerPort: 464
protocol: UDP
- name: kpasswd-tcp
containerPort: 464
protocol: TCP
- name: kadmin
containerPort: 749
protocol: TCP
volumeMounts:
- name: knoe-kdc-config
mountPath: /opt/knoe-kdc
- name: knoe-kdc-data
mountPath: /var/lib/krb5kdc
- name: knoe-kdc-etc
mountPath: /etc/krb5kdc
volumes:
- name: keytab
emptyDir: {}
- name: keytab-secret
secret:
secretName: knoe-auth-keytab
optional: true
- name: knoe-kdc-config
configMap:
name: knoe-kdc-config
defaultMode: 0755
- name: knoe-kdc-data
emptyDir: {}
- name: knoe-kdc-etc
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: knoe-auth
namespace: knoe-system
labels:
app: knoe-auth
spec:
selector:
app: knoe-auth
ports:
- name: http
port: 80
targetPort: 8080
- name: krb5-tcp
port: 88
targetPort: 88
protocol: TCP
- name: krb5-udp
port: 88
targetPort: 88
protocol: UDP
- name: kadmin
port: 749
targetPort: 749
protocol: TCP
type: ClusterIP

View File

@ -0,0 +1,108 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: knoe-kdc-config
namespace: knoe-system
data:
krb5.conf: |
[libdefaults]
default_realm = KNOE.DEV
dns_lookup_realm = false
dns_lookup_kdc = false
ticket_lifetime = 10h
renew_lifetime = 7d
forwardable = true
[realms]
KNOE.DEV = {
kdc = 127.0.0.1
admin_server = 127.0.0.1
}
[domain_realm]
.knoe.dev = KNOE.DEV
knoe.dev = KNOE.DEV
kdc.conf: |
[kdcdefaults]
kdc_ports = 88
kdc_tcp_ports = 88
[realms]
KNOE.DEV = {
database_name = /var/lib/krb5kdc/principal
admin_keytab = FILE:/etc/krb5kdc/kadm5.keytab
acl_file = /etc/krb5kdc/kadm5.acl
key_stash_file = /etc/krb5kdc/stash
max_life = 10h 0m 0s
max_renewable_life = 7d 0h 0m 0s
default_principal_flags = +preauth
}
kadm5.acl: |
admin/admin@KNOE.DEV *
entrypoint.sh: |
#!/usr/bin/env bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
realm="${KNOE_KDC_REALM:-KNOE.DEV}"
admin_principal="${KNOE_KDC_ADMIN_PRINCIPAL:-admin/admin}"
if [[ "${admin_principal}" != *"@"* ]]; then
admin_principal="${admin_principal}@${realm}"
fi
if ! command -v krb5kdc >/dev/null 2>&1; then
echo "Installing Kerberos packages..."
echo "krb5-config krb5-config/default_realm string ${realm}" | debconf-set-selections || true
echo "krb5-config krb5-config/kerberos_servers string 127.0.0.1" | debconf-set-selections || true
echo "krb5-config krb5-config/admin_server string 127.0.0.1" | debconf-set-selections || true
apt-get update -qq
apt-get install -y --no-install-recommends krb5-kdc krb5-admin-server krb5-user dnsutils ca-certificates
rm -rf /var/lib/apt/lists/*
fi
mkdir -p /etc/krb5kdc /var/lib/krb5kdc
if [[ -f /opt/knoe-kdc/krb5.conf ]]; then
cp /opt/knoe-kdc/krb5.conf /etc/krb5.conf
fi
if [[ -f /opt/knoe-kdc/kdc.conf ]]; then
cp /opt/knoe-kdc/kdc.conf /etc/krb5kdc/kdc.conf
fi
if [[ -f /opt/knoe-kdc/kadm5.acl ]]; then
cp /opt/knoe-kdc/kadm5.acl /etc/krb5kdc/kadm5.acl
fi
if [[ -z "${KNOE_KDC_MASTER_PASSWORD:-}" ]]; then
echo "ERROR: Missing required env KNOE_KDC_MASTER_PASSWORD" >&2
exit 1
fi
if [[ -z "${KNOE_KDC_ADMIN_PASSWORD:-}" ]]; then
echo "ERROR: Missing required env KNOE_KDC_ADMIN_PASSWORD" >&2
exit 1
fi
if [[ ! -f /var/lib/krb5kdc/principal ]]; then
echo "Initializing realm database for ${realm}..."
kdb5_util create -s -r "${realm}" -P "${KNOE_KDC_MASTER_PASSWORD}"
fi
if ! kadmin.local -q "get_principal ${admin_principal}" >/dev/null 2>&1; then
echo "Creating admin principal ${admin_principal}..."
kadmin.local -q "addprinc -pw ${KNOE_KDC_ADMIN_PASSWORD} ${admin_principal}"
fi
# system admin user principal
if ! kadmin.local -q "get_principal admin@${realm}" >/dev/null 2>&1; then
echo "Creating admin user principal admin@${realm}..."
kadmin.local -q "addprinc -pw ${KNOE_KDC_MASTER_PASSWORD} admin@${realm}"
fi
echo "Starting krb5kdc and kadmind ..."
krb5kdc -n &
sleep 0.5
if ! pgrep -x krb5kdc >/dev/null 2>&1; then
echo "ERROR: krb5kdc failed to start." >&2
exit 1
fi
exec kadmind -nofork

View File

@ -0,0 +1,28 @@
---
# knoe-kdc-secrets.yaml
# Placeholder / example structure. Real values are created by init_knoe_auth.sh
# using 1Password (op) and applied directly — this file is NOT committed with
# real secrets.
#
# To create manually:
# kubectl -n knoe-system create secret generic knoe-kdc-secrets \
# --from-literal=master_password="$(op item get 'knoe-kdc-master' --fields password)" \
# --from-literal=admin_password="$(op item get 'knoe-kdc-admin' --fields password)"
#
# Fields:
# master_password — KDC database master key (kdb5_util -P)
# admin_password — admin/admin@KNOE.DEV principal password (kadmin)
#
# Secret is referenced by:
# - knoe-kdc (KDC sidecar container)
# - keytab-bootstrap (initContainer)
apiVersion: v1
kind: Secret
metadata:
name: knoe-kdc-secrets
namespace: knoe-system
type: Opaque
stringData:
master_password: "REPLACE_WITH_STRONG_RANDOM_VALUE"
admin_password: "REPLACE_WITH_STRONG_RANDOM_VALUE"

View File

@ -16,7 +16,7 @@ spec:
app: garage app: garage
spec: spec:
nodeSelector: nodeSelector:
kubernetes.io/hostname: myrddin.knoe.org kubernetes.io/hostname: myrddin.prole.org
containers: containers:
- name: garage - name: garage
image: dxflrs/garage:v1.3.1 image: dxflrs/garage:v1.3.1

View File

@ -16,7 +16,7 @@ spec:
spec: spec:
initContainers: initContainers:
- name: keytab-bootstrap - name: keytab-bootstrap
image: myrddin.knoe.org:5000/knoe-authority:latest image: myrddin.prole.org:5000/knoe-authority:latest
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /bin/bash - /bin/bash
@ -202,7 +202,7 @@ spec:
subPath: krb5.conf subPath: krb5.conf
readOnly: true readOnly: true
- name: kdc - name: kdc
image: myrddin.knoe.org:5000/knoe-authority:latest image: myrddin.prole.org:5000/knoe-authority:latest
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: ["/bin/bash", "/opt/knoe-kdc/entrypoint.sh"] command: ["/bin/bash", "/opt/knoe-kdc/entrypoint.sh"]
env: env:

View File

@ -15,8 +15,8 @@ data:
admin_server = 127.0.0.1 admin_server = 127.0.0.1
} }
PROLE.ORG = { PROLE.ORG = {
kdc = myrddin.knoe.org kdc = myrddin.prole.org
admin_server = myrddin.knoe.org admin_server = myrddin.prole.org
} }
[capaths] [capaths]

View File

@ -56,7 +56,7 @@ spec:
app: opentofu app: opentofu
spec: spec:
nodeSelector: nodeSelector:
kubernetes.io/hostname: myrddin.knoe.org kubernetes.io/hostname: myrddin.prole.org
containers: containers:
- name: opentofu-ui - name: opentofu-ui
image: nginx:1.27-alpine image: nginx:1.27-alpine

775
docs/pipeline-phases.md Normal file
View File

@ -0,0 +1,775 @@
# Knoe.DB Autobuild & Test Pipeline — Phase Reference
> **How to use this document**
> Each phase has a **Status** line, a **Commit Summary** section (filled in after the phase lands), and a **Resumption Checklist** so any developer can pick up where we left off without needing context from a previous session. Update the Status and Commit Summary before starting the next phase.
---
## Context
The project has **four deployment modes** that must each produce a stable, reproducible build:
| Mode | What it is | CI tool |
|---|---|---|
| `min` | Single `knoe-db` container via containerd, no Kubernetes | Gitea Actions (bare macOS runner) |
| `k3d` | k3s-in-Docker local cluster — CNPG + optional Supabase / ArgoCD / Gitea / GitLab | Gitea Actions (Docker-enabled runner) |
| `k3s` | Homelab multi-node k3s on physical hardware | Deferred (evaluate after GitLab) |
| `gke` | Dual GKE clusters on Google Cloud (production) | GitLab CI |
**Primary goal:** auto-build on every push to `main` so we have confidence that builds are stable before we touch anything else.
**Secondary goal:** once we have stable builds, identify unused code with `vulture` + coverage diff, then prune toward an *instructable* codebase — a project a junior developer can read and fully understand.
---
## Phase 0 — Foundation
**Status:** ✅ Complete
**Branch / PRs:** landed directly on `main`; pushed to `origin` (`git-ssh.knoe.dev:knoe.dev/knoe-db`)
### What this phase does
Fixes the broken test infrastructure so IntelliJ can discover tests and `make test` actually measures the right code. No new tests are written here — just scaffolding that every subsequent phase depends on.
### Deliverables
| File | Change |
|---|---|
| `pyproject.toml` | Single source of truth for pytest config + coverage config. Replaces the broken `.coveragerc` (which pointed to `installer/` — a directory that no longer exists) |
| `requirements-test.txt` | Explicit test dependencies (pytest, coverage, vulture, etc.) separated from runtime deps |
| `.idea/runConfigurations/pytest_all.xml` | IntelliJ: run all tests with coverage |
| `.idea/runConfigurations/pytest_unit.xml` | IntelliJ: run unit-only tests (fast, no external deps) |
| `.idea/runConfigurations/pytest_min.xml` | IntelliJ: run tests tagged `@pytest.mark.min` |
| `.idea/runConfigurations/pytest_k3d.xml` | IntelliJ: run tests tagged `@pytest.mark.k3d` |
| `.idea/runConfigurations/pytest_gke.xml` | IntelliJ: run tests tagged `@pytest.mark.gke` |
| `tests/run_tests.sh` | Fix `--cov` source from `install` to `knoe` |
### Commit Summary
```
97575b9 docs: update Phase 0 commit summary and resumption checklist
0052a4d Phase 0: test pipeline foundation — pyproject.toml, IntelliJ run configs, coverage fix, welcome mode selector
```
The following commits landed on the same branch as part of the Cowork+Code thread consolidation (WIP that had accumulated alongside Phase 0):
```
837da27 docs: update README; IntelliJ run config picks up Python 3.14 SDK
52fe440 chore(k3s): script and hostprobe updates, temp maintenance scripts
dd8c9d1 chore(scripts): init script updates — gitea, gitlab, kong, monitoring, registry
a754659 chore(deploy): cluster config and k3s manifest updates
4995c86 chore(build): Maven version and authority module updates
ca469aa chore(installer): core Python updates — env, milestones, monitoring
5cd9c12 feat(auth): init scripts and k3s/k8s auth manifests for knoe-auth
2a80df8 feat(auth): land Round 1 — invite-OTP enrollment, kadmin client, GKE manifests
6c72c76 docs(plans): add platform architecture plans — deployment-modes, knoe-auth round 1
```
### Known pre-existing issues (fix in Phase 1, not Phase 0)
`knoe/core/ops/cloudnative_pg.py:1372` contains:
```python
f"jsonpath={{.data.{field.replace('.', '\\.')}}}",
```
Python ≥ 3.12 allows backslashes in f-strings (PEP 701) but this still triggers a `SyntaxError` on Python 3.14 under certain parse modes. This causes **30 test collection errors** — the tests themselves are not broken. Fix: extract the replacement to a variable before the f-string. Tagged as Phase 1 work.
### Resumption Checklist
Before picking up work on Phase 1, verify:
- [x] `pytest tests/` collects 441+ tests from the project root
- [x] `pytest -m unit` runs and reports a coverage number against `knoe/`
- [x] IntelliJ shows the five run configs in the Run/Debug Configurations dropdown
- [x] `python -c "from knoe.ui.screens import KnoeInstaller"` succeeds
- [x] The 30 collection errors in `cloudnative_pg.py` are logged as Phase 1 work
---
## Phase 1 — `min` Mode Pipeline
**Status:** 🔲 Not started — ready to begin
**Depends on:** Phase 0 complete ✅
**First task:** fix `knoe/core/ops/cloudnative_pg.py:1372` f-string syntax (30 collection errors, see Phase 0 known issues)
### What this phase does
Establishes the first green autobuild. The `min` pipeline is the simplest possible CI: a bare macOS runner (no Docker, no Kubernetes) running pytest. It exercises the welcome-screen mode selector, the min-mode navigation fast-path, and the `init_min.sh` script. Trigger: push to `main` on the Gitea remote.
### Architecture
```
push to main (Gitea)
Gitea Actions
act_runner ←── bare macOS (dev machine or Mac mini)
│ labels: [self-hosted, macos, min]
▼ requires: Homebrew + Python 3.11
pytest -m "min or unit"
coverage.xml ──► uploaded as artifact
```
**Runner setup (one-time, not in CI):**
```bash
# On the macOS runner machine:
brew install gitea-act-runner
act_runner register \
--instance https://<gitea-host>/ \
--token <runner-token-from-gitea-settings> \
--labels "self-hosted,macos,min" \
--name "knoe-min-runner"
act_runner daemon
```
### Deliverables
| File | Purpose |
|---|---|
| `.gitea/workflows/ci-min.yml` | Gitea Actions workflow — push-triggered, runs on bare macOS runner |
| `tests/modes/__init__.py` | Package marker |
| `tests/modes/test_min_mode.py` | Mode-specific tests for min path |
| `tests/modes/conftest.py` | Mode fixtures (mock containerd, mock 1Password CLI) |
### `.gitea/workflows/ci-min.yml`
```yaml
name: CI — min mode
on:
push:
branches: [main]
jobs:
min-unit:
runs-on: [self-hosted, macos, min]
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -r requirements.txt -r requirements-test.txt
- name: Run min + unit tests
run: |
pytest -m "min or unit" \
--cov=knoe \
--cov-report=xml \
--cov-report=term-missing \
-v \
tests/
env:
PYTHONPATH: ${{ github.workspace }}
KNOE_MODE: min
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-min
path: coverage.xml
retention-days: 14
```
### Tests to write in `tests/modes/test_min_mode.py`
Each test name is intentionally human-readable — they become living documentation for junior developers.
| Test | What it proves |
|---|---|
| `test_welcome_mode_selector_starts_with_no_selection` | `deployment_mode` defaults to `""` on fresh install — forces explicit choice |
| `test_clicking_min_card_sets_cluster_env_to_min` | Card click wires `cluster_env = "min"` correctly |
| `test_clicking_min_card_enables_next_button` | Welcome Next is disabled until a card is clicked |
| `test_min_nav_env_setup_goes_to_init_password_not_cluster` | Verifies min fast-path in `on_next` |
| `test_min_nav_init_scripts_goes_to_security_not_kerberos` | Min skips Kerberos, ArgoCD, GitOps, Supabase |
| `test_min_nav_prev_from_init_password_returns_to_env_setup` | Symmetric back-navigation |
| `test_min_nav_prev_from_security_returns_to_init_scripts` | Symmetric back-navigation |
| `test_normalize_cluster_env_min_returns_min` | `_normalize_cluster_env("min") == "min"` |
| `test_deployment_mode_from_env_min_returns_min` | `_deployment_mode_from_env("min") == "min"` |
| `test_init_min_sh_runs_without_error` | `subprocess` call to `init_min.sh initialize` exits 0 (macOS only, `@pytest.mark.min`) |
### Commit Summary
*(Fill in after phase lands)*
```
# example:
# abc1234 Phase 1: add .gitea/workflows/ci-min.yml
# def5678 Phase 1: add tests/modes/test_min_mode.py
# ghi9012 Phase 1: add tests/modes/conftest.py
```
### Resumption Checklist
- [ ] Push to `main` triggers `ci-min` workflow in Gitea Actions UI
- [ ] `pytest -m "min or unit"` exits 0 locally
- [ ] All 10 tests in `test_min_mode.py` pass
- [ ] Coverage artifact appears in the Gitea Actions run summary
- [ ] Badge shows green on Gitea repo homepage (optional but nice)
---
## Phase 2 — `k3d` Mode Pipeline
**Status:** 🔲 Not started
**Depends on:** Phase 1 complete
### What this phase does
Adds the k3d integration pipeline — a Docker-enabled runner that creates a real k3d cluster, deploys CNPG, verifies the database is reachable, then tears it down. This is the first pipeline that proves an actual database cluster starts correctly. Estimated runtime: 815 minutes.
### Architecture
```
push to main (Gitea)
Gitea Actions
├─ job: k3d-unit (fast, no cluster) ─────────────► coverage-k3d-unit.xml
└─ job: k3d-integration (depends on unit) ──────► coverage-k3d-integration.xml
├── k3d cluster create knoe-ci
├── apply CNPG operator
├── apply knoe-db Cluster CR
├── pytest -m "k3d and integration"
└── k3d cluster delete knoe-ci (always)
```
**Runner setup (one-time):**
Same `act_runner` binary but registered with labels `self-hosted,macos,k3d` on a Docker-enabled machine (Docker Desktop or Colima).
### Deliverables
| File | Purpose |
|---|---|
| `.gitea/workflows/ci-k3d.yml` | Two-job workflow: unit then integration |
| `tests/modes/test_k3d_mode.py` | k3d unit + integration tests |
| `tests/modes/fixtures/knoe-db-test.yaml` | Minimal CNPG `Cluster` CR for test use (single instance, tiny storage) |
| `tests/modes/fixtures/cnpg-operator-values.yaml` | Minimal Helm values for CNPG operator in CI |
### `.gitea/workflows/ci-k3d.yml`
```yaml
name: CI — k3d mode
on:
push:
branches: [main]
jobs:
k3d-unit:
runs-on: [self-hosted, macos, k3d]
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -r requirements.txt -r requirements-test.txt
- name: Unit tests (k3d, no cluster)
run: pytest -m "k3d and unit" --cov=knoe --cov-report=xml tests/
env:
PYTHONPATH: ${{ github.workspace }}
KNOE_MODE: k3d
- uses: actions/upload-artifact@v4
with: { name: coverage-k3d-unit, path: coverage.xml }
k3d-integration:
runs-on: [self-hosted, macos, k3d]
needs: k3d-unit
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -r requirements.txt -r requirements-test.txt
- name: Install k3d + kubectl
run: |
brew install k3d kubectl
- name: Create test cluster
run: |
k3d cluster create knoe-ci \
--agents 1 \
--k3s-arg '--disable=traefik@server:0' \
--wait
kubectl cluster-info --context k3d-knoe-ci
- name: Deploy CNPG operator
run: |
kubectl apply --server-side \
-f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/main/releases/cnpg-1.23.0.yaml
kubectl wait --for=condition=Available \
deployment/cnpg-controller-manager \
-n cnpg-system --timeout=120s
- name: Integration tests (live cluster)
run: |
pytest -m "k3d and integration" \
--cov=knoe \
--cov-report=xml \
-v \
tests/
env:
PYTHONPATH: ${{ github.workspace }}
KNOE_MODE: k3d
KUBECONFIG: ${{ env.HOME }}/.kube/config
- name: Tear down cluster
if: always()
run: k3d cluster delete knoe-ci
- uses: actions/upload-artifact@v4
with: { name: coverage-k3d-integration, path: coverage.xml }
```
### Tests to write in `tests/modes/test_k3d_mode.py`
**Unit tests** (`@pytest.mark.k3d @pytest.mark.unit`):
| Test | What it proves |
|---|---|
| `test_k3d_cluster_env_normalizes_to_dev` | `_normalize_cluster_env("dev") == "dev"` |
| `test_welcome_k3d_card_sets_cluster_env_dev` | Card click → `cluster_env = "dev"` |
| `test_k3d_nav_proceeds_through_cluster_screens` | k3d mode does NOT skip `init_cluster` |
| `test_k3d_supabase_option_visible` | Supabase toggle is reachable in k3d nav flow |
| `test_cluster_lifecycle_milestone_uses_k3d_script` | `ClusterLifecycleMilestone` calls k3d cluster-create command for `dev` env |
**Integration tests** (`@pytest.mark.k3d @pytest.mark.integration`):
| Test | What it proves |
|---|---|
| `test_k3d_cluster_api_is_reachable` | kubectl can reach `k3d-knoe-ci` API server |
| `test_cnpg_operator_crds_registered` | `Cluster` CRD exists after operator deploy |
| `test_knoe_db_cluster_pod_starts` | Apply test CR → at least one PostgreSQL pod reaches `Running` |
| `test_database_accepts_connections` | psql `SELECT 1` succeeds against the CNPG service |
| `test_cluster_delete_is_clean` | After delete, no k3d cluster named `knoe-ci` remains |
### Commit Summary
*(Fill in after phase lands)*
### Resumption Checklist
- [ ] Push to `main` triggers both `k3d-unit` and `k3d-integration` jobs in Gitea
- [ ] `k3d-integration` passes (PostgreSQL pod reaches Running state)
- [ ] Both coverage artifacts appear in the run summary
- [ ] `k3d cluster list` shows no leftover `knoe-ci` cluster after the run
---
## Phase 3 — GKE Pipeline (GitLab CI, expanded)
**Status:** 🔲 Not started
**Depends on:** Phase 2 complete
### What this phase does
Replaces the current single-job `.gitlab-ci.yml` (which only runs `install.sh -S`) with a proper multi-stage pipeline: lint → unit tests → GKE integration tests → dead-code report → deploy. The unit test gate runs on every push; GKE integration runs on `main` only.
### Architecture
```
push to any branch
├─ stage: lint → python syntax + imports check
├─ stage: test → pytest -m "unit" (every push)
push to main only:
├─ stage: test → pytest -m "gke and integration" (real GKE cluster)
├─ stage: test → vulture dead-code report (allow_failure: true)
└─ stage: deploy → ./install.sh -S -c conf/service/prod.cfg
```
### Deliverables
| File | Purpose |
|---|---|
| `.gitlab-ci.yml` | Full multi-stage pipeline replacing current single-job version |
| `tests/modes/test_gke_mode.py` | GKE unit + integration tests |
| `scripts/dead_code_analysis.py` | Cross-references coverage.json with vulture-report.txt, outputs three-tier report |
### Required GitLab CI variables (project settings → CI/CD → Variables)
| Variable | Value | Protected | Masked |
|---|---|---|---|
| `GCP_SA_KEY` | GCP service account JSON with `container.viewer` + `storage.objectViewer` on both clusters | ✅ | ✅ |
| `GKE_REGION` | `us-west3` | | |
| `GKE_APP_CLUSTER` | `knoe-dev-0` | | |
| `GKE_DB_CLUSTER` | `knoe-cnpg-0` | | |
### `.gitlab-ci.yml` (full replacement)
```yaml
stages:
- lint
- test
- report
- deploy
variables:
PYTHON_VERSION: "3.11"
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths: [.cache/pip]
# ── Stage: lint ───────────────────────────────────────────────────────────────
lint:
stage: lint
image: python:3.11-slim
script:
- pip install -r requirements-test.txt -q
- python -m py_compile knoe/**/*.py
- python -c "from knoe.ui.screens import KnoeInstaller"
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
# ── Stage: test (unit — every push) ──────────────────────────────────────────
unit-tests:
stage: test
image: python:3.11-slim
script:
- pip install -r requirements.txt -r requirements-test.txt -q
- pytest -m "unit and not integration"
--cov=knoe
--cov-report=xml
--cov-report=term-missing
-q
tests/
coverage: '/TOTAL.*\s+(\d+\%)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths: [coverage.xml]
expire_in: 7 days
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
# ── Stage: test (GKE integration — main only) ─────────────────────────────────
gke-integration:
stage: test
image: google/cloud-sdk:slim
timeout: 30 minutes
before_script:
- pip install -r requirements.txt -r requirements-test.txt -q
- echo "$GCP_SA_KEY" | gcloud auth activate-service-account --key-file=-
- gcloud config set project plenary-truck-485623-p7
- gcloud container clusters get-credentials $GKE_APP_CLUSTER
--region $GKE_REGION
- gcloud container clusters get-credentials $GKE_DB_CLUSTER
--region $GKE_REGION
script:
- pytest -m "gke and integration"
--cov=knoe
--cov-report=xml
-v
tests/
coverage: '/TOTAL.*\s+(\d+\%)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths: [coverage.xml]
expire_in: 30 days
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
# ── Stage: report (dead code — main only, never blocks build) ─────────────────
dead-code:
stage: report
image: python:3.11-slim
allow_failure: true
script:
- pip install vulture -q
- vulture knoe/ --min-confidence 80 | tee vulture-report.txt
- python scripts/dead_code_analysis.py
--vulture vulture-report.txt
--coverage coverage.xml
--output dead-code-report.md
artifacts:
paths:
- vulture-report.txt
- dead-code-report.md
expire_in: 30 days
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
# ── Stage: deploy (main only) ─────────────────────────────────────────────────
deploy-service:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- ./install.sh -S -c conf/service/prod.cfg
environment:
name: production
```
### Tests to write in `tests/modes/test_gke_mode.py`
**Unit tests** (`@pytest.mark.gke @pytest.mark.unit`):
| Test | What it proves |
|---|---|
| `test_gke_cluster_env_normalizes_to_prod` | `_normalize_cluster_env("prod") == "prod"` |
| `test_welcome_gke_card_sets_cluster_env_prod` | Card click → `cluster_env = "prod"` |
| `test_gke_split_cluster_detected` | When `app_ctx != db_ctx`, split-cluster mode activates |
| `test_garage_not_deployed_to_db_cluster` | CNPG cluster does not include Garage in GKE mode |
| `test_gke_storage_class_validation` | `standard-rwo` raises quota error hint; `standard` passes |
**Integration tests** (`@pytest.mark.gke @pytest.mark.integration`):
| Test | What it proves |
|---|---|
| `test_gke_app_cluster_reachable` | kubectl can reach `knoe-dev-0` |
| `test_gke_db_cluster_reachable` | kubectl can reach `knoe-cnpg-0` |
| `test_cnpg_cluster_knoe_db_0_running` | `knoe-db` cluster in `knoe-db-0` namespace has 3 Ready instances |
| `test_barman_backup_schedule_exists` | ScheduledBackup CR exists in `knoe-db-0` |
| `test_garage_only_in_app_cluster` | No Garage pods in `knoe-cnpg-0` namespace |
### Commit Summary
*(Fill in after phase lands)*
### Resumption Checklist
- [ ] GitLab pipeline shows four stages: lint → test → report → deploy
- [ ] `unit-tests` job passes on every push (not just main)
- [ ] `gke-integration` passes on main (3 CNPG pods Running)
- [ ] `dead-code` job produces a `dead-code-report.md` artifact
- [ ] Deploy stage still works (`install.sh -S` exits 0)
- [ ] GitLab shows coverage percentage on the merge request widget
---
## Phase 4 — Dead Code Identification
**Status:** 🔲 Not started
**Depends on:** Phase 3 complete (all three stable builds achieved)
### What this phase does
Runs the first systematic dead code analysis across the full codebase. This is not a cleanup sprint — it is *reconnaissance*. We generate a prioritised report and review it before deleting anything. The report becomes the input for Phase 5.
### Why this matters
The project has undergone several major renames (`prole` → `knoe`, various directory restructures). Code written for old layouts is likely still present. `knoe/core/actions.py` alone is 7,740 lines and almost certainly contains branches that no deployment mode ever exercises.
### Tools
| Tool | Role |
|---|---|
| `vulture` | Static analysis — finds unused functions, classes, imports |
| `pytest --cov --cov-branch` | Dynamic analysis — lines/branches never executed during tests |
| `scripts/dead_code_analysis.py` | Cross-references both outputs to produce a tiered report |
### Three-tier output format
```markdown
## 🔴 Definite dead code (vulture-flagged AND 0% coverage)
- knoe/core/ops/legacy_shell.py:47 — function `_old_prole_exec` (unused, 0% coverage)
- ...
## 🟡 Suspect (vulture-flagged OR 0% coverage, not both)
- knoe/core/actions.py:4201 — function `_k8s_node_drain` (0% coverage, not flagged by vulture)
- ...
## 🟢 Live (covered by tests and vulture-clean)
- (omitted from report for brevity)
```
### How to run locally
```bash
# 1. Full coverage run across all modes
KNOE_MODE=min pytest -m min --cov=knoe --cov-append tests/
KNOE_MODE=k3d pytest -m k3d --cov=knoe --cov-append tests/
KNOE_MODE=gke pytest -m gke --cov=knoe --cov-append tests/
coverage json # produces coverage.json
# 2. Vulture scan
vulture knoe/ --min-confidence 70 > vulture-report.txt
# 3. Cross-reference
python scripts/dead_code_analysis.py \
--vulture vulture-report.txt \
--coverage coverage.json \
--output dead-code-report.md
# 4. Review
open dead-code-report.md
```
### Deliverables
| File | Purpose |
|---|---|
| `scripts/dead_code_analysis.py` | Cross-reference script (coverage.json + vulture output → tiered report) |
| `dead-code-report.md` | Generated output — committed to repo for review, not production |
| `docs/dead-code-review.md` | Human review notes — which 🔴 items are safe to delete vs accidentally flagged |
### Commit Summary
*(Fill in after phase lands)*
### Resumption Checklist
- [ ] `python scripts/dead_code_analysis.py` runs without error
- [ ] `dead-code-report.md` contains all three tiers
- [ ] The 🔴 list has been manually reviewed and each item categorised as "safe to delete" or "keep" in `docs/dead-code-review.md`
- [ ] No production code deleted yet — this phase is report-only
---
## Phase 5 — Pruning and the Instructable Codebase
**Status:** 🔲 Not started
**Depends on:** Phase 4 report reviewed and approved
### What this phase does
This is the refactoring sprint. Using the Phase 4 report, we delete dead code in small, test-verified commits, raising the `fail_under` coverage threshold after each deletion. The end state is a codebase that a junior developer can read from top to bottom and fully understand.
### Target metrics
| Metric | Today (est.) | Target |
|---|---|---|
| Test coverage (unit) | ~40% | ≥ 80% |
| Test coverage (integration) | ~10% | ≥ 60% |
| `vulture --min-confidence 80` warnings | Unknown | 0 |
| Lines in `knoe/core/actions.py` | 7,740 | < 2,000 |
| Modules with zero test coverage | ~17 (`knoe/core/ops/`) | 0 |
| `fail_under` in `pyproject.toml` | 0 | 75 |
### Deletion protocol
For each 🔴 item from the Phase 4 report:
1. Write a test that would fail if the code were still needed (proves it's safe to delete)
2. Delete the code
3. Run `pytest` — all tests pass
4. Commit with message: `prune: remove <module/function> — dead code (vulture + 0% coverage)`
5. Raise `fail_under` by 12 points
This protocol means every deletion is backed by a test. The test suite grows *because* we prune.
### The instructable codebase standard
A module is "instructable" when:
- Every public function has at least one test whose name reads as a plain English sentence
- The test file for the module is shorter than the module itself
- A junior developer can understand the module's purpose from the test names alone without reading the source
Example of instructable test names:
```python
def test_min_mode_only_needs_homebrew_and_1password(): ...
def test_k3d_mode_creates_cnpg_cluster_in_docker(): ...
def test_welcome_card_click_routes_to_correct_nav_flow(): ...
def test_kerberos_is_only_enabled_in_k3s_and_gke_modes(): ...
```
### Commit Summary
*(Fill in after each deletion sprint)*
### Resumption Checklist
- [ ] `fail_under` has been raised at least once since Phase 4
- [ ] Zero 🔴 items remain in `dead-code-report.md`
- [ ] All modules in `knoe/core/ops/` have at least one test
- [ ] CI passes on all three pipelines (min, k3d, gke)
---
## k3s Phase — Homelab Pipeline
**Status:** ⏸ Deferred
**Trigger:** Evaluate after GitLab CI (Phase 3) is stable
### Notes for when this is ready
- Runner: one of the physical k3s nodes (`myrddin.prole.org`, `gandalf.prole.org`, or `merlin.prole.org`) registered as a Gitea act_runner with labels `self-hosted,linux,k3s`
- Integration tests will need `kubeconfig` for the k3s cluster and iSCSI storage access for CNPG
- Kerberos integration is the unique test target here — `knoe-auth` deployment with embedded KDC
- The `CNPG_ELIGIBLE_NODES` config var maps directly to tests that verify node affinity placement
---
## Appendix A — Pytest Marker Reference
| Marker | When to use |
|---|---|
| `@pytest.mark.unit` | No external services, no file system writes, runs anywhere |
| `@pytest.mark.integration` | Requires a running cluster, database, or network service |
| `@pytest.mark.min` | Tests the min (containerd) deployment path |
| `@pytest.mark.k3d` | Tests the k3d (local Docker cluster) deployment path |
| `@pytest.mark.k3s` | Tests the k3s (homelab) deployment path |
| `@pytest.mark.gke` | Tests the GKE (production) deployment path |
Combining markers is the norm: `@pytest.mark.k3d @pytest.mark.integration` means "requires a live k3d cluster".
---
## Appendix B — Runner Registration Quick Reference
### Gitea act_runner (macOS)
```bash
# Install
brew install act-runner
# Register (run once per machine)
act_runner register \
--instance https://<gitea-host>/ \
--token <token-from-gitea-settings-actions-runners> \
--labels "self-hosted,macos,min" # or min,k3d for the Docker machine \
--name "knoe-min-runner" # human-readable name in Gitea UI
# Start as a service
brew services start act-runner
```
### GitLab runner (existing)
The existing GitLab runner is assumed to have `gcloud` CLI available. If not:
```bash
# On the runner host:
curl https://sdk.cloud.google.com | bash
gcloud components install gke-gcloud-auth-plugin
```
---
## Appendix C — Coverage Increment Strategy
Rather than setting an ambitious `fail_under` up front and having CI permanently broken, we raise it in steps as each phase lands:
| After phase | `fail_under` |
|---|---|
| Phase 0 (foundation) | 0 (measure only) |
| Phase 1 (min tests) | 15 |
| Phase 2 (k3d tests) | 25 |
| Phase 3 (gke tests) | 35 |
| Phase 4 (dead code pruned) | 50 |
| Phase 5 (full prune) | 75 |
Each increment is a one-line change to `pyproject.toml` committed at the end of the phase.

View File

@ -1,24 +1,43 @@
# Plans (prole — customer deploy) # Plans
This directory holds **plans specific to the prole.org customer deployment** of the knoe.dev platform. Engineering plans for the **knoe.dev** platform. Each document scopes one initiative — its strategic context, design, schema, and the components it adds — and is intended to outlive the implementation work itself: when the work ships, the plan stays as the architectural reference.
## Who this is for
You are a junior or mid-level engineer who has been invited to contribute to knoe.dev. These documents are written for you. They assume:
- You are comfortable reading code and skimming a Maven `pom.xml`.
- You have used Kubernetes at least casually (`kubectl get pods`, helm charts).
- You may **not** have prior Kerberos, OIDC, or CNPG experience — relevant terms are defined inline or in the glossary section of each plan.
- You have not seen this repo before. Each plan starts with the strategic context and links to existing code before it asks you to write any.
If a sentence in a plan assumes knowledge you don't have, that's a bug in the plan — open an issue.
## What lives here ## What lives here
| File | Purpose | | File | Initiative | When you should read it |
| --- | --- | | --- | --- | --- |
| [`customer-deploy-resync.md`](customer-deploy-resync.md) | The active plan to converge this working tree onto `knoe-db/main` as a long-lived customer branch. | | [`knoe-auth-round-1.md`](knoe-auth-round-1.md) | Identity backbone for the platform: MIT Kerberos KDC + invite-anchored web enrollment + TOTP 2FA. Round 1 of N. **Shipped.** | Before touching `authority/`, `etc/init_kdc.sh`, `etc/init_knoe_users.sh`, anything in `deploy/gcp/gke/knoe-auth-*` or `knoe-kdc-*`, or the `knoe.*` database schema. |
| [`deployment-modes.md`](deployment-modes.md) | Four-mode installer (`min` / `k3d` / `k3s` / `gke`) with a welcome-screen mode selector and a `min`-mode fast-path through the wizard. **Shipped in Phase 0.** | Before touching `knoe/ui/screens/welcome.py`, `knoe/ui/screens/navigation.py`, or adding any new wizard screen. |
## What does **not** live here Each plan follows the same shape: **Context → How it's wired (file references) → Architecture → Schema / API → Step-by-step → Verification → Glossary.**
Platform-level plans live upstream in `~/dev/knoe-db/docs/plans/`: ## Status conventions
- [`knoe-auth-round-1.md`](../../../knoe-db/docs/plans/knoe-auth-round-1.md) — Kerberos identity + invite-OTP enrollment + TOTP. Architectural reference. A plan in this directory has one of three statuses, declared at the top:
- [`deployment-modes.md`](../../../knoe-db/docs/plans/deployment-modes.md) — Four-mode installer + welcome-screen selector. Architectural reference.
(Paths above are relative to your `~/dev/` root. If you're reading this on the file server, navigate to the `knoe-db` checkout.) - **Active plan. Not yet implemented.** — the design is agreed, the code isn't there yet. Read deliverables top-to-bottom.
- **Implemented and operational. Architectural reference.** — the work shipped. The doc explains how it works and *why*; cross-references point at real files. Read the context and the rationale; jump to specific sections when you have a question.
- **Archived.** — superseded by a later round or a different approach. Lives in `archive/` with a one-line "see X instead" pointer.
## Why the split ## Customer deploys
`prole` is being converged into a customer-deploy branch in `knoe-db`, not a separate fork. Platform docs travel with the platform code (in `knoe-db`); customer-deploy-specific docs travel with the customer branch (here). When the customer branch is published in `knoe-db`, this directory will move with it. The knoe.dev platform supports per-customer deployments — currently the most active is `prole.org`. The convention is **branches in this repo**, not separate forks. Customer-specific divergence (config, branding, on-prem manifests, kubeconfig handling) lives on a branch named after the customer; platform changes always land on `main` and customer branches rebase or merge from main on a regular cadence. See the customer's own `docs/plans/` for resync notes — for example, `~/dev/prole/docs/plans/customer-deploy-resync.md` if you have that working tree checked out.
See the upstream `~/dev/knoe-db/docs/plans/README.md` for the full conventions on writing plans, plan statuses, and how customer deploys are structured. ## How to propose a new plan
1. Copy `knoe-auth-round-1.md` as a template — the section structure is the convention.
2. Lead with **why** (one or two paragraphs). Half the value of a plan is forcing the author to articulate the motivation.
3. Cite real files with paths relative to the repo root. If you reference something that doesn't exist yet, label it **(net-new)** so a reader doesn't go hunting.
4. End with a **Verification** section — a short checklist a reviewer can run to decide whether the plan is done, or — once shipped — that the implementation still matches the spec.
5. Open an MR. Plans are reviewed like code.

View File

@ -0,0 +1,188 @@
# Deployment Modes — Welcome Screen + Wizard Fast-Path
**Status:** Implemented and operational. Architectural reference. Welcome-screen mode selector and `min`-mode fast-path shipped in Phase 0.
**Owner:** chrisfu
**Audience:** Jr/mid engineer onboarding to knoe.dev. Familiarity with Tk/tkinter helps but is not required.
---
## 1. Context
The knoe-db installer (`./install.sh` / `./knoe.sh install`, driven by the Tk UI in `knoe/ui/screens/`) supports four ways to deploy the platform. Different audiences, different hardware, but **the same architecture underneath**.
Earlier installer revisions assumed the engineer already knew which mode they wanted, presented a welcome screen that was a wall of text, and shipped a sidebar tab strip (`k3d` / `k3s` / `k8s`) that didn't reliably refresh state across screens. Phase 0 replaced all of that with a first-screen mode selector that teaches the engineer what each mode *is* before asking them to choose, and added a `min`-mode fast-path that skips wizard screens irrelevant to a single-container deployment.
### The four modes
| Mode | What it is | Who it's for | Required tools |
| --- | --- | --- | --- |
| **min** | A single `knoe-db` container under `containerd` — no Kubernetes, no Docker. The Spring Boot app runs locally on the host. | Engineers running a Spring app on a laptop and wanting a CNPG-compatible Postgres without a full cluster. | Homebrew, 1Password |
| **k3d** | A Docker-based local k3s cluster. Full CNPG + optional Supabase / ArgoCD / Gitea / GitLab. | Local development with the full platform. | Docker, Homebrew, 1Password |
| **k3s** | A multi-node k3s cluster on real hardware. Full Kerberos auth stack, Garage S3, monitoring. Mirrors the GKE shape. | Homelab contributors with their own hardware (e.g. a 3-node Pi cluster). | k3sup, Homebrew, 1Password |
| **gke** | The production dual-cluster on Google Cloud (`knoe-dev-0` + `knoe-cnpg-0` in `us-west3`). | Production. | gcloud, kubectl, 1Password |
The architectural commitment matters: **all four modes are scaled-down mirrors of the GKE shape.** Not parallel implementations of the same idea, not divergent forks — the same components composed at different scales. CNPG runs in all four. The Spring Boot `authority` service runs in all four. `knoe.user` lives in the same schema in all four. The only thing that changes is what platform the components run on and which optional services are enabled.
### Mode → cluster_env mapping
| `cluster_env` | `KNOE_MODE` | Target |
| --- | --- | --- |
| `dev` | `k3d` | Local K3d cluster |
| `service` | `k3s` | On-prem K3s cluster |
| `prod` | `k8s` (alias `gke`) | GKE (or other cloud) |
| `min` | `min` | Local containerd (no Kubernetes) |
The conversion lives in `knoe/core/env.py` (`_deployment_mode_from_env()`).
### Config file mapping
`knoe/knoe_conf.py` maps environments to config files under `conf/`:
| Mode | Config file |
| --- | --- |
| `min` | `conf/min.cfg` |
| `k3d` (`dev`) | `conf/k3d.cfg` |
| `k3s` (`service`) | `conf/k3s.cfg` |
| `gke` (`prod`) | `conf/gke.cfg` |
Config is layered: env-specific file overrides base. `KNOE_CONF` env var or `conf/service/` subdirs point to the active config.
---
## 2. How it's wired — file map
| Component | Location | What's there |
| --- | --- | --- |
| Welcome screen with mode selector | `knoe/ui/screens/welcome.py` | `WelcomeScreenMixin._render_welcome_page()` renders the four mode cards. Click handler updates `deployment_mode` and `cluster_env`, redraws card borders, re-evaluates the Next gate. |
| Sidebar nav | `knoe/ui/screens/navigation.py` | `_create_sidebar_nav()` no longer renders the old `k3d/k3s/k8s` tab strip — the welcome card selector replaces it. `_set_deployment_mode` is still called from the welcome handler. |
| Mode/state initialization | `knoe/ui/screens/__init__.py` | `nav_items` list, `deployment_mode = tk.StringVar(...)`, `cluster_env = tk.StringVar(...)`. |
| Wizard transitions | `knoe/ui/screens/navigation.py` `on_next` / `on_prev` | Min-mode fast-path skips screens that don't apply (network scan, cluster init, Kerberos, ArgoCD, Supabase, common services). |
| Update-footer Next-button gate | `knoe/ui/screens/navigation.py` `update_footer` | Welcome screen disables Next until a mode is selected. |
| Min-mode bootstrap | `etc/init_min.sh` | Idempotent: ensures Homebrew + containerd, pulls and runs the `knoe-db` container locally, runs schema bootstrap, prints connection info. Invoked from `init_scripts` step in min mode. |
| Min-mode config | `conf/min.cfg` | Modeled on `conf/k3d.cfg`. Just what `init_min.sh` needs. |
| Min-mode lifecycle | `knoe.sh` (`start` / `stop` / `restart` subcommands) | Manages the local containerd `knoe-db` instance for users who don't want to run the wizard every time. |
---
## 3. UI layout — welcome screen
```
┌──────────────────────────────────────────────────────────────────┐
│ │
│ knoe.dev │
│ infrastructure.auto() │
│ │
│ Welcome │
│ ─────── │
│ Knoe.DB sets up a production-grade PostgreSQL cluster with │
│ Kerberos authentication. Pick the mode that matches your │
│ hardware — we'll walk you through every step. │
│ │
│ ┌────────────┬────────────┬────────────┬────────────┐ │
│ │ min │ k3d │ k3s │ gke │ │
│ │ Just a │ Local │ Homelab │ Production│ │
│ │ Database │ Cluster │ │ │ │
│ │ One knoe-db│ k3s-in- │ Multi-node │ Dual GKE │ │
│ │ container │ Docker │ k3s on real│ clusters on│ │
│ │ via │ cluster on │ hardware. │ Google │ │
│ │ containerd.│ your Mac. │ Full │ Cloud. │ │
│ │ No K8s. │ Full CNPG │ Kerberos │ CNPG + │ │
│ │ Perfect for│ database — │ auth stack,│ GCS backups│ │
│ │ a Spring │ add │ Garage S3, │ + Workload │ │
│ │ app on your│ Supabase, │ monitoring.│ Identity. │ │
│ │ laptop. │ ArgoCD, │ │ │ │
│ │ │ Gitea, or │ │ │ │
│ │ │ GitLab. │ │ │ │
│ │ Homebrew + │ Docker + │ k3sup + │ gcloud + │ │
│ │ 1Password │ Homebrew + │ Homebrew + │ 1Password │ │
│ │ │ 1Password │ 1Password │ │ │
│ └────────────┴────────────┴────────────┴────────────┘ │
│ │
│ You'll need: Docker · Homebrew · 1Password │
│ │
│ [ Next → ] │
└──────────────────────────────────────────────────────────────────┘
```
Behavior:
- The four cards are clickable.
- The selected card gets a colored border in the mode's accent color (`_MODE_COLORS`: `min` purple, `k3d` blue, `k3s` green, `gke` orange).
- The "You'll need:" line below the cards updates to reflect the selected mode's tools.
- The Next button is **disabled** until a mode is selected.
- Selecting a card also sets `cluster_env` via the mode-to-cluster_env map.
- On Next, mode is persisted to `knoe_cfg_data["Global"]["DEPLOYMENT_MODE"]`.
---
## 4. Wizard transitions — `min`-mode fast-path
### Default flow (k3d / k3s / gke)
```
welcome → network_scan → env_setup → init_cluster → cluster_nodes →
init_password → init_scripts → kerberos_config → knoe_users →
argocd_config → gitops_config → supabase_config → common_services →
… → security
```
### Min-mode flow
```
welcome → dependencies → environment → init_password → init_scripts → security
```
Skipped in min: `network_scan`, `init_cluster`, `cluster_nodes`, `kerberos_config`, `knoe_users`, `argocd_config`, `gitops_config`, `supabase_config`, `common_services`. None of them apply when there's no Kubernetes cluster and no Kerberos KDC. `init_scripts` invokes `etc/init_min.sh` instead of the cluster-mode init scripts.
The fast-path is implemented as a `_min_mode_next` dictionary in `knoe/ui/screens/navigation.py` consulted from `on_next` / `on_prev`. The override is only active when `_is_min_mode()` returns true.
---
## 5. Verification
Run after any change touching the wizard or mode logic.
1. `./install.sh` opens. The welcome screen shows four mode cards. The sidebar has **no** k3d/k3s/k8s tab strip.
2. With no mode selected, the Next button is disabled.
3. Click the **min** card. Next becomes enabled. Click Next.
- The wizard skips the network-scan screen.
- It skips `init_cluster` and `cluster_nodes`.
- It skips `kerberos_config`, `knoe_users`, `argocd_config`, `gitops_config`, `supabase_config`, `common_services`.
- It runs `init_min.sh` at the `init_scripts` step.
- It lands on the `security` screen.
- Going back through `on_prev` walks the same trimmed sequence in reverse without hitting any skipped screens.
4. Click the **k3d** card on a fresh run. The wizard follows the existing k3d flow exactly as before.
5. `python3 -c "from knoe.ui.screens import KnoeInstaller"` — no import errors.
6. After completing min mode, `nerdctl ps` shows the `knoe-db` container running locally and `psql "${printed_conn_string}" -c '\dt knoe.*'` lists the `knoe.user` table.
7. `./knoe.sh stop` and `./knoe.sh start` cleanly stop/restart the local containerd instance.
---
## 6. Out of scope
- A `min`-mode equivalent of the full Kerberos auth stack. Min is "just a database". Auth in min is a follow-up.
- Migrating an existing `k3d` deployment to `min` (or any other mode-to-mode migration). Each mode is an independent target.
- A unified package format that produces all four mode-specific installers from one `pyinstaller` build. The current per-mode build is fine.
- Letting one wizard run install *multiple* modes (e.g. set up `k3d` and `gke` from the same session). One mode per run.
---
## 7. Glossary
**k3s** — A lightweight Kubernetes distribution by Rancher Labs. Single binary, runs comfortably on a Raspberry Pi. The same project, two delivery shapes: `k3d` is k3s wrapped in Docker; bare k3s is k3s on actual hardware.
**k3d** — A wrapper that runs k3s clusters inside Docker containers. Useful for local dev because it gives you a "real" Kubernetes cluster without having to provision VMs.
**GKE (Google Kubernetes Engine)** — Google Cloud's managed Kubernetes. We use Standard for `knoe-dev-0` and `knoe-cnpg-0`. See `CLAUDE.md` for cluster topology.
**containerd** — A container runtime. Speaks the OCI image and runtime specs. Both Docker and Kubernetes use containerd under the hood. In `min` mode we talk to it directly via `nerdctl`, skipping Docker entirely.
**nerdctl** — A Docker-CLI-compatible client for `containerd`. If you know `docker run`, you know `nerdctl run`.
**CNPG (CloudNativePG)** — The Postgres operator we use for `knoe-db`. See `CLAUDE.md` for which cluster runs it in production.
**`cluster_env`** — A label the installer carries internally that picks which `conf/*.cfg` file to read and which downstream config branches to take. Maps from `deployment_mode` per the table in §1. Don't reuse this name for new variables — it's already overloaded enough.
**Tk / tkinter** — Python's standard GUI toolkit. The installer is built on it. Canvas-based rendering means we draw text and shapes directly rather than using Tk's widget hierarchy for the welcome screen.
**Wizard / pid** — The installer is a multi-screen wizard. Each screen has a "page id" (`pid`) — `welcome`, `network_scan`, `init_cluster`, etc. `nav_items` is the ordered list; `on_next` / `on_prev` decide transitions based on the current `pid` and the user's state.

View File

@ -0,0 +1,483 @@
# knoe-auth Round 1 — Durable Kerberos Identity + Contributor Onboarding
**Status:** Implemented and operational. Architectural reference.
**Owner:** chrisfu
**Audience:** Jr/mid engineer onboarding to knoe.dev. No prior Kerberos or OIDC experience assumed.
---
## 1. Context
`knoe-auth` is the identity system for the knoe.dev platform — the thing that decides who you are, what you can access, and how new contributors come on board. It is a long-term, multi-round initiative. This document is the architectural reference for **Round 1**, which is shipped.
### The chicken-and-egg problem
To build a sophisticated identity provider safely, the team needs to be able to authenticate themselves against *something* trustworthy in the meantime. We needed a working auth store *before* we could safely build the better one.
Round 1 leans on a tool that has been doing this job for thirty-five years: **MIT Kerberos**. It is unfashionable but well-understood, cryptographically sound, and we already had it running in our k3s homelab cluster. We extended it to GKE, wrapped a small enrollment portal around it, and use that to onboard contributing engineers while Round 2 (a full OIDC provider) is being built.
### Why Kerberos
Kerberos *principals* are durable, DNS-like identifiers. `chrisfu@KNOE.DEV` is a stable cryptographic identity that survives any change to our web stack, our database, or our cloud provider. Once a principal exists in the KDC, it can issue tickets that any kerberized service trusts — Spring Boot via SPNEGO, Postgres via `gss` auth, SSH, NFS — without each of those services needing its own user table.
That's the asset Round 1 builds on.
### Two independent Google Workspaces — do not conflate
| Workspace | Role |
| --- | --- |
| `knoe.dev` | Internal Google Workspace for the platform. Has zero pre-knowledge of any contributor's home org. |
| `prole.org` | Workspace of the first contributing engineer's organization. Independently operated. |
`knoe.dev` does **not** trust `prole.org` as a domain. `prole.org` is just one engineer's email provider, no different from `gmail.com` or any other workspace a future contributor might use. Trust between knoe.dev and a new contributor is bootstrapped by the **invite**, not by the contributor's home Google domain.
### Trust model — the most important paragraph in this document
When a new engineer enrolls, the trust sequence is:
1. **An admin sends an invite to a specific email address or phone number.** That contact channel — and only that channel — is the trust anchor. The admin's choice of who to invite **is** the policy.
2. **The engineer proves they control that contact** by entering a one-time password (OTP) delivered to it. Until the OTP verifies, no further steps are possible.
3. **Only after the OTP gate** is the engineer offered a Google sign-in to *corroborate* their identity. The Google sign-in is welcomed in *after* trust is already established by the invite — it is not the source of trust.
4. **TOTP** (the rotating six-digit code from Google Authenticator / Authy) is set up as the ongoing 2FA credential.
5. **Only then** is a Kerberos principal minted, a `knoe.user` row inserted, and downstream provisioning jobs (GitLab account, Gitea account) queued.
This means knoe.dev never needs to pre-configure trust with any external workspace. The OAuth2 app does **not** restrict by Google `hd` (hosted domain) — any verified Google account works. The contributor's home domain is *recorded* for audit (`knoe.identity.provider_hd`) but never used to gate access.
If you remember nothing else: **the invite OTP is the trust anchor. Google is corroboration. TOTP is the ongoing factor.**
---
## 2. How it's wired — file map
The actual files that implement Round 1. Verify with `git ls-files` before assuming any of the below has rotted.
### Java application — `authority/`
The Spring Boot service that implements the enrollment flow, the admin API, and SPNEGO-protected endpoints. Multi-module Maven build (`authority/pom.xml`); the application package is `org.prole.authority` (kept as-is across the prole→knoe rebrand for compatibility).
| File | Responsibility |
| --- | --- |
| `authority/src/main/java/org/prole/authority/KnoeAuthApplication.java` | `@SpringBootApplication` entry point. |
| `authority/src/main/java/org/prole/authority/HealthController.java` | `/health` endpoint. |
| `authority/src/main/java/org/prole/authority/web/LoginController.java` | Form-login + SPNEGO challenge for browsers without a ticket. |
| `authority/src/main/java/org/prole/authority/web/VerifyController.java` | Token-verify endpoint for downstream services. |
| `authority/src/main/java/org/prole/authority/session/SessionTokenService.java` | Issues HMAC-SHA256 JWT cookies after successful auth. |
| `authority/src/main/java/org/prole/authority/session/SessionUser.java` | Authenticated principal carried in the security context. |
| `authority/src/main/java/org/prole/authority/user/PrincipalNormalizer.java` | Strips realm/instance from a Kerberos principal (`alice/admin@KNOE.DEV` → `alice`). |
| `authority/src/main/java/org/prole/authority/kerberos/KerberosSpnegoService.java` | SPNEGO challenge/response handling. |
| `authority/src/main/java/org/prole/authority/kerberos/KerberosPasswordService.java` | Password-style auth fallback for browsers that can't do SPNEGO. |
| `authority/src/main/java/org/prole/authority/kerberos/KadminClient.java` | Shells out to `kadmin.local` (in the KDC sidecar) to `addprinc` and `cpw`. **Sanitizes input.** |
| `authority/src/main/java/org/prole/authority/enroll/EnrollmentController.java` | Web endpoints: `GET /auth/enroll`, `POST /auth/enroll/verify-otp`, `POST /auth/enroll/identity/start`, `GET /auth/enroll/google-callback`, `GET /auth/enroll/totp`, `POST /auth/enroll/totp/verify`, `POST /auth/enroll/complete`. |
| `authority/src/main/java/org/prole/authority/enroll/InviteService.java` | CRUD + validation against `knoe.invitation`. OTP hashing (bcrypt) and rate limiting (3 attempts). |
| `authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java` | Exchange OAuth2 code → ID token, validate `email_verified`, return a `GoogleIdentity` record. **No `hd` allowlist.** |
| `authority/src/main/java/org/prole/authority/enroll/TotpService.java` | Generate TOTP secret, produce `otpauth://` URI, verify codes. |
| `authority/src/main/java/org/prole/authority/enroll/UserProvisioningService.java` | Transactional orchestrator: inserts user/identity/totp rows, calls `KadminClient`, queues provisioning jobs. |
| `authority/src/main/java/org/prole/authority/admin/AdminController.java` | `POST /auth/admin/invites`, `GET /auth/admin/users`, `POST /auth/admin/grants`. SPNEGO + admin-role gated. |
| `authority/src/main/java/org/prole/authority/admin/KnobjectService.java` | CRUD on `knoe.knobject` and `knoe.access_grant`; enqueues `provisioning_job` rows. |
| `authority/src/main/java/org/prole/authority/provisioning/ProvisioningWorker.java` | `@Scheduled` poller for `knoe.provisioning_job WHERE status = 'pending'`. Dispatches to GitLab/Gitea/CNPG. |
| `authority/src/main/java/org/prole/authority/config/AuthProperties.java` | Typed binding for `knoe.auth.*` keys. |
| `authority/src/main/java/org/prole/authority/config/KerberosProperties.java` | Typed binding for `knoe.auth.kerberos.*` keys. |
Tests for the above live under `authority/src/test/java/org/prole/authority/` — notably `web/VerifyControllerTest.java` and `session/SessionTokenServiceTest.java`.
### Kubernetes manifests — GKE
| File | Purpose |
| --- | --- |
| `deploy/gcp/gke/knoe-kdc-configmap.yaml` | `krb5.conf` + `kdc.conf` for the `KNOE.DEV` realm. |
| `deploy/gcp/gke/knoe-kdc-secrets.yaml` | Master key + admin password references. Production values come from OpenBao. |
| `deploy/gcp/gke/knoe-auth-deployment.yaml` | KDC sidecar + Spring Boot pod. Service principal `HTTP/auth.knoe.dev@KNOE.DEV`. |
| `deploy/gcp/gke/knoe-auth-google-oidc-secret.example.yaml` | Templated Secret with `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` placeholders. Real values are not committed. |
| `deploy/gcp/gke/workload-identity.yaml` | KSA↔GSA bindings for any GCP-backed secret access. |
### Kubernetes manifests — k3s (homelab and customer deploys)
| File | Purpose |
| --- | --- |
| `deploy/opentofu/k3s/manifests/prole/prole-kdc-configmap.yaml` | The `PROLE.LOCAL` realm KDC for the homelab cluster. The pattern the GKE configmap was modeled on. |
| `deploy/opentofu/k3s/manifests/prole/prole-kdc-secrets.example.yaml` | Templated secrets for the same. |
| `deploy/opentofu/k3s/manifests/prole/prole-auth-deployment.yaml` | KDC + Spring Boot for the homelab. |
| `deploy/opentofu/k3s/manifests/prole/prole-auth-kerberos-configmap.yaml` | `krb5.conf` for the auth pod's Kerberos client. |
### Bootstrap scripts — `etc/`
| File | Purpose |
| --- | --- |
| `etc/init_kdc.sh` | Provisions the in-cluster KDC. Idempotent. Read this end-to-end before writing anything that interacts with the KDC. |
| `etc/init_knoe_users.sh` | Creates `knoe.user`, `knoe.user_role`, and (per Round 1) the additional auth tables; seeds initial principals via `kadmin.local`. |
| `etc/init_kerberos.sh` | Cluster-wide krb5.conf wiring for kerberized services (Postgres, etc.). |
---
## 3. Architecture
### The invite-to-enrolled flow at a glance
```
Invite URL
https://auth.knoe.dev/enroll?token=<uuid>
├─ Step 1: Enter OTP (delivered to invite email/phone)
│ ─ Trust anchor. Without this, no further steps.
├─ Step 2: Pick a username, link with Google (any account, any hd)
│ ─ Corroboration. Records provider_sub + provider_hd for audit.
├─ Step 3: Scan QR with authenticator app, verify TOTP code
│ ─ Sets up the ongoing 2FA factor.
└─ Step 4: System provisions:
├─ Kerberos principal: <username>@KNOE.DEV
├─ knoe.user row + knoe.identity link to Google subject
├─ knoe.totp_credential row (encrypted secret)
└─ Async queue: GitLab account, Gitea account, …
```
### Cluster topology
Round 1 lives in the GKE app cluster (`knoe-dev-0`) in the `knoe-system` namespace. The database stays where it already is — the dedicated CNPG cluster `knoe-cnpg-0`. See `CLAUDE.md` for the cluster layout and storage-quota rules.
```
knoe-dev-0 / knoe-system namespace:
┌─ knoe-auth (Spring Boot) ─────────────────────────────────────────┐
│ /health │
│ /auth/login, /auth/spnego, /auth/verify │
│ /auth/enroll/* (invite → OTP → Google → TOTP → provision) │
│ /auth/admin/* (create invites, manage knobjects) │
└────────────────────────────────────────────────────────────────────┘
│ kadmin.local calls (KDC is a sidecar in the same pod)
┌─ knoe-kdc (MIT Kerberos, KNOE.DEV realm) ─────────────────────────┐
│ Container pattern from prole-kdc-configmap.yaml │
│ Realm: KNOE.DEV │
│ Cross-realm trust with PROLE.LOCAL: deferred to Round 2 │
└────────────────────────────────────────────────────────────────────┘
┌─ CNPG / knoe-db (in knoe-cnpg-0) ─────────────────────────────────┐
│ knoe.user (base table) │
│ knoe.user_role (base table) │
│ knoe.invitation (Round 1) │
│ knoe.identity (Round 1 — Google sub → knoe user) │
│ knoe.totp_credential (Round 1 — encrypted TOTP secret + backups) │
│ knoe.knobject (Round 1 — platform resources) │
│ knoe.access_grant (Round 1 — user → knobject grants) │
│ knoe.provisioning_job (Round 1 — async outbox) │
└────────────────────────────────────────────────────────────────────┘
```
The KDC runs as a sidecar in the same pod as the Spring Boot app. They share the pod network namespace, so `kadmin.local` calls reach the KDC over loopback — no Kubernetes Service required between them. This is the same pattern the k3s deployment uses.
---
## 4. Schema
The `knoe.*` schema lives in CNPG (`knoe-cnpg-0` namespace `knoe-db-0`). Base tables (`knoe.user`, `knoe.user_role`) are created by `etc/init_knoe_users.sh` lines 482510. Round 1 added the six tables below; they are created by the same script later in its run.
```sql
-- ────────────────────────────────────────────────────────────────────
-- knoe.invitation — admin creates one of these per invited engineer.
-- The (contact, otp_hash) pair IS the trust anchor for that engineer.
-- ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS knoe.invitation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT NOT NULL UNIQUE, -- URL token (long, random)
contact TEXT NOT NULL, -- email or phone the invite was sent to
contact_type TEXT NOT NULL DEFAULT 'email',-- 'email' | 'sms'
name_hint TEXT, -- optional display-name hint from admin
otp_hash TEXT NOT NULL, -- bcrypt of the 6-digit OTP
otp_expires_at TIMESTAMPTZ NOT NULL, -- short TTL (10 min)
otp_attempts INT NOT NULL DEFAULT 0, -- max 3 before invalidation
otp_verified_at TIMESTAMPTZ, -- set when OTP passes — gate for steps 2-4
created_by TEXT NOT NULL, -- admin username
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL, -- invite URL TTL (72h)
used_at TIMESTAMPTZ, -- set at step-4 completion
used_by TEXT -- knoe username after use
);
-- ────────────────────────────────────────────────────────────────────
-- knoe.identity — external identity corroborations.
-- Round 1 only writes Google rows here; future providers reuse the table.
-- ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS knoe.identity (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES knoe.user(id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'google'
provider_sub TEXT NOT NULL, -- Google subject ID (stable per user)
provider_email TEXT,
provider_hd TEXT, -- 'prole.org' | 'gmail.com' | NULL — audit only
verified_at TIMESTAMPTZ NOT NULL,
UNIQUE(provider, provider_sub)
);
-- ────────────────────────────────────────────────────────────────────
-- knoe.totp_credential — the rotating 2FA factor for ongoing logins.
-- ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS knoe.totp_credential (
user_id INT PRIMARY KEY REFERENCES knoe.user(id) ON DELETE CASCADE,
secret TEXT NOT NULL, -- AES-GCM encrypted, key in OpenBao
verified_at TIMESTAMPTZ, -- NULL until first successful verification
backup_codes TEXT[], -- bcrypt-hashed one-time recovery codes
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ────────────────────────────────────────────────────────────────────
-- knoe.knobject — platform-managed resources (a "knobbed object",
-- something an admin can hand to a user).
-- ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS knoe.knobject (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL, -- 'gitea_repo' | 'gitlab_project' | 'cnpg_role' | 'openbao_policy'
name TEXT NOT NULL,
platform_id TEXT, -- external identifier on target platform
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(type, name)
);
-- ────────────────────────────────────────────────────────────────────
-- knoe.access_grant — user ← knobject with role.
-- ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS knoe.access_grant (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES knoe.user(id),
knobject_id INT NOT NULL REFERENCES knoe.knobject(id),
role TEXT NOT NULL, -- 'owner' | 'developer' | 'viewer'
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
UNIQUE(user_id, knobject_id)
);
-- ────────────────────────────────────────────────────────────────────
-- knoe.provisioning_job — async outbox.
-- A worker bean inside knoe-auth polls this table.
-- ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS knoe.provisioning_job (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES knoe.user(id),
job_type TEXT NOT NULL, -- 'create_gitlab_user' | 'create_gitea_user' | 'grant_cnpg_role'
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'running' | 'done' | 'failed'
payload JSONB,
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
---
## 5. Configuration
### `authority/.../application.properties` — relevant keys
```properties
knoe.auth.google.client-id=${GOOGLE_CLIENT_ID}
knoe.auth.google.client-secret=${GOOGLE_CLIENT_SECRET}
knoe.auth.google.redirect-uri=https://auth.knoe.dev/auth/enroll/google-callback
# No allowed-domains list. Trust is established by invite OTP, not by the
# developer's home Google domain. provider_hd is recorded in knoe.identity
# for audit, never used for access control.
knoe.auth.enroll.invite-ttl-hours=72
knoe.auth.enroll.otp-ttl-minutes=10
knoe.auth.enroll.otp-max-attempts=3
knoe.auth.enroll.totp-issuer=knoe.dev
knoe.auth.provisioning.poll-interval-ms=10000
```
### `pom.xml` — relevant dependencies
```xml
<!-- TOTP -->
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp-spring-boot-starter</artifactId>
<version>1.7.1</version>
</dependency>
<!-- Google OAuth2 client -->
<dependency>
<groupId>com.google.api-client</groupId>
<artifactId>google-api-client</artifactId>
<version>2.4.0</version>
</dependency>
```
### Google OAuth2 app
Configured by hand in the GCP console under project `plenary-truck-485623-p7`.
- **Authorized redirect URIs:**
- `https://auth.knoe.dev/auth/enroll/google-callback` (Round 1 enrollment)
- `https://git.knoe.dev/...` (Gitea OIDC, future round)
- `https://git.prole.org/...` (GitLab OIDC, future round)
- **No `hd=` restriction** at the OAuth app level. Deliberate.
- Client ID and secret stored in a Kubernetes Secret modeled on `deploy/gcp/gke/knoe-auth-google-oidc-secret.example.yaml`.
---
## 6. Step-by-step enrollment flow
This is what the engineer being onboarded actually experiences, what the server actually does, and where the trust transitions live.
### Step 0 — Admin creates the invite
```
POST /auth/admin/invites
Body: { contact: "chrisfu@prole.org", contact_type: "email", name_hint: "Chris Fu" }
Server:
generate invite_token (UUID v4)
generate otp (6-digit numeric, 10-min TTL)
hash otp with bcrypt → otp_hash
insert into knoe.invitation
send email to chrisfu@prole.org:
Subject: "You've been invited to knoe.dev"
Body: invite URL + "Your verification code: 847291"
```
The OTP is delivered through the **same channel** as the invite URL. Both arrive in the engineer's inbox. The OTP is *not* sent to a side channel — its purpose is to prove possession of that inbox.
### Step 1 — Engineer proves contact ownership (the trust gate)
```
GET /auth/enroll?token=<uuid>
→ InviteService validates token is not expired/used
→ renders landing.html with the OTP entry form
POST /auth/enroll/verify-otp { otp: "847291" }
→ InviteService compares bcrypt(otp) with otp_hash, checks expiry
→ on success: set otp_verified_at=now(), advance session to step 2
→ on failure: increment otp_attempts; if >= 3, invalidate the invite and return 403
```
After this step, the session is gated. The remaining steps are unreachable without a verified OTP. **This is where the trust transition happens.**
### Step 2 — Engineer picks a username and links Google
```
GET /auth/enroll/identity
→ renders identity.html — username field + display-name field +
"Sign in with Google" button
POST /auth/enroll/identity/start { username: "chrisfu", display_name: "Chris Fu" }
→ store proposed username + display name in session
→ redirect to Google OAuth2 authorize URL
(state=<session_id>, nonce=<random>, NO hd parameter)
GET /auth/enroll/google-callback?code=<code>&state=<session_id>
→ GoogleOAuthService exchanges code for ID token
→ validates id_token.email_verified == true (REQUIRED)
→ records provider_sub, provider_email, provider_hd
(provider_hd is whatever Google reports — prole.org, gmail.com, etc.)
→ stores GoogleIdentity in session, advances to step 3
```
This step is **corroboration**, not authorization. The engineer's home Google workspace is not a trust source. We accept any verified Google account and record which domain it came from for audit purposes.
### Step 3 — Engineer sets up TOTP
```
GET /auth/enroll/totp
→ TotpService.generateSecret() — 160-bit base32 secret
→ store the encrypted secret in the session (NOT yet in the DB)
→ render totp-setup.html with:
• a QR code encoding otpauth://totp/knoe.dev:<username>?secret=...&issuer=knoe.dev
• the 16-character manual key for users with no QR scanner
• "Open Google Authenticator / Authy and scan this code"
POST /auth/enroll/totp/verify { code: "123456" }
→ TotpService.verify(sessionSecret, code) — validates within ±1 30-second window
→ on success: advance to step 4
→ on failure: re-render with the same secret (don't rotate yet)
```
### Step 4 — System provisions
```
POST /auth/enroll/complete
→ UserProvisioningService.provision(session) runs in a single transaction:
1. INSERT INTO knoe.user (username, realm='KNOE.DEV', email, display_name)
2. INSERT INTO knoe.identity (provider='google', sub, email, hd)
3. INSERT INTO knoe.totp_credential (AES-encrypted secret, verified_at=now())
4. KadminClient.addPrincipal("<username>@KNOE.DEV")
5. UPDATE knoe.invitation SET used_at=now(), used_by=<username>
6. INSERT INTO knoe.provisioning_job (job_type='create_gitea_user', payload={...})
7. INSERT INTO knoe.provisioning_job (job_type='create_gitlab_user', payload={...})
→ render complete.html with the engineer's new username and a "what happens next"
summary (their dev environment is being set up async, they'll get a follow-up email).
```
### Properties of this flow you can rely on
- The OTP delivery channel is the identity proof. If the OTP arrives, the engineer owns that inbox.
- knoe.dev never trusted `prole.org`. It trusted the admin's choice to send the invite to a `prole.org` address. Different thing.
- The Google link captures the engineer's home workspace as audit data, but does not gate access.
- TOTP becomes the ongoing 2FA factor. The Google sign-in is a one-time corroboration; logins after enrollment use Kerberos + TOTP.
- `knoe.identity.provider_hd` records the home domain without pre-judging it.
---
## 7. Verification
Run this end-to-end after any change touching the auth/Kerberos surface.
1. `kubectl -n knoe-system get pods``knoe-auth` and `knoe-kdc` both Running.
2. `curl https://auth.knoe.dev/health` returns 200.
3. Hit `POST /auth/admin/invites` from an admin SPNEGO session, receive an invite URL.
4. Open the enrollment URL in a fresh browser, complete all four steps using a real Google account in a non-`knoe.dev` workspace (e.g. `gmail.com`).
5. `psql … -c "SELECT username, realm FROM knoe.user WHERE username = '<test_user>';"` returns the row.
6. `kinit <test_user>@KNOE.DEV` from a machine that trusts the realm — succeeds.
7. `psql … -c "SELECT job_type, status FROM knoe.provisioning_job WHERE user_id = (SELECT id FROM knoe.user WHERE username = '<test_user>');"` shows `create_gitea_user` and `create_gitlab_user` rows.
8. After the polling interval, those rows transition to `status = 'done'` and the corresponding accounts exist on the platforms.
---
## 8. Out of scope for Round 1
Real, named items the team has discussed. They are **not** in Round 1 — when the team gets to them, each becomes its own plan in this directory.
- Cross-realm trust between `KNOE.DEV` and `PROLE.LOCAL` (so a `chrisfu@PROLE.LOCAL` ticket can talk to a `KNOE.DEV` service). Round 2.
- A full OIDC provider hosted by knoe-auth, replacing the dependence on Google for downstream services. Round 2.
- SSO into kerberized Postgres roles (`gss` auth) so `knoe.user` rows map directly to database principals.
- A "knobject inspector" admin UI. Right now the admin API is JSON-only.
- Phone/SMS-based OTP delivery. Round 1 covers email; the `contact_type` column is already present on `knoe.invitation` so adding SMS later is additive.
- Self-service password rotation, recovery flows, and deactivation. Admin-only for Round 1.
---
## 9. Glossary
**KDC** — Key Distribution Center. The Kerberos server. Holds the master key for the realm; issues TGTs (ticket-granting tickets) and service tickets.
**Kerberos principal** — A named identity in a realm. Format: `name@REALM` (or `service/host@REALM`). Example: `chrisfu@KNOE.DEV`. Long-lived, cryptographic, decoupled from any particular service's user table.
**Realm** — A Kerberos administrative domain. Independent KDCs each own their own realm. `PROLE.LOCAL` and `KNOE.DEV` are two realms; cross-realm trust is configured separately.
**Keytab** — A file containing one or more principals' long-term keys, used by services to authenticate to the KDC without an interactive password. Spring Boot reads its service principal's keytab at startup.
**SPNEGO** — Simple and Protected GSSAPI Negotiation Mechanism. The HTTP-layer protocol that lets a browser holding a Kerberos ticket authenticate to a web app over the wire. RFC 4178. Spring Security has built-in support.
**kadmin / kadmin.local** — The KDC's administration tool. `kadmin` runs over the network with admin credentials; `kadmin.local` runs on the KDC host itself, bypassing the network protocol. Round 1 calls `kadmin.local` from a sidecar container.
**TGT (Ticket-Granting Ticket)** — The first ticket the KDC issues to a user after they prove their identity. Used to request further service tickets without re-entering credentials.
**TOTP** — Time-based One-Time Password. RFC 6238. The rotating six-digit code Google Authenticator and Authy show. A shared secret + the current Unix time bucketed into 30-second windows produces the code.
**OAuth2** — Authorization framework. Lets a user grant an app limited access to their account at another service. Concerns *delegation*, not *identity*.
**OIDC (OpenID Connect)** — An identity layer on top of OAuth2. Adds the `id_token` (a signed JWT with claims about the user). When we say "Google sign-in" we mean OIDC over Google's OAuth2.
**`hd` claim** — In a Google OIDC `id_token`, the user's hosted-domain (i.e. their Google Workspace). Optional, present only for Workspace accounts. We *record* it in `knoe.identity.provider_hd` for audit but do **not** gate access on it.
**Workload Identity** — GKE feature that binds a Kubernetes ServiceAccount to a Google Cloud IAM service account. Lets pods talk to GCP APIs (e.g. KMS for our master key) without long-lived JSON keyfiles.
**CNPG** — CloudNativePG. The Postgres operator we use for `knoe-db`. Runs in `knoe-cnpg-0`. See `CLAUDE.md` for cluster topology.
**knobject** — A platform-managed resource that an admin can hand out to a user — a Gitea repo, a Postgres role, an OpenBao policy. The name is a portmanteau of "knoe" + "object". Modeled by `knoe.knobject`; granted via `knoe.access_grant`.
**Round 1, Round 2, …** — Versioning convention for the auth roadmap. Each round is a self-contained, shippable increment. Round 1 stops where the platform can safely onboard contributing engineers; Round 2 introduces the OIDC provider; later rounds tighten the screws.

View File

@ -4,7 +4,7 @@ metadata:
name: knoe-hostprobe-myrddin name: knoe-hostprobe-myrddin
namespace: kube-system namespace: kube-system
spec: spec:
nodeName: myrddin.knoe.org nodeName: myrddin.prole.org
hostNetwork: true hostNetwork: true
hostPID: true hostPID: true
tolerations: tolerations:

View File

@ -4,7 +4,7 @@ metadata:
name: knoe-hostprobe-pi name: knoe-hostprobe-pi
namespace: kube-system namespace: kube-system
spec: spec:
nodeName: pi.knoe.org nodeName: pi.prole.org
hostNetwork: true hostNetwork: true
hostPID: true hostPID: true
tolerations: tolerations:

View File

@ -1,15 +1,15 @@
myrddin.knoe.org 10.0.0.3 myrddin.prole.org 10.0.0.3
raspberry.knoe.org 10.0.0.4 raspberry.prole.org 10.0.0.4
pi.knoe.org 10.0.0.5 pi.prole.org 10.0.0.5
synology.knoe.org 10.0.0.203 synology.prole.org 10.0.0.203
morgoth.knoe.org 10.0.0.204 morgoth.prole.org 10.0.0.204
zinfandel.knoe.org 10.0.0.205 zinfandel.prole.org 10.0.0.205
aventage.knoe.org 10.0.0.206 aventage.prole.org 10.0.0.206
retropie.knoe.org 10.0.0.207 retropie.prole.org 10.0.0.207
fairyland.knoe.org 10.0.0.208 fairyland.prole.org 10.0.0.208
k8s.knoe.org zinfandel.knoe.org k8s.prole.org zinfandel.prole.org
mc.knoe.org 73.15.20.166 mc.prole.org 73.15.20.166
morana.knoe.org 10.0.0.66 morana.prole.org 10.0.0.66
ollama.knoe.org 73.15.20.166 ollama.prole.org 73.15.20.166
svc.knoe.org 73.15.20.166 svc.prole.org 73.15.20.166
www.knoe.org ghs.googlehosted.com www.prole.org ghs.googlehosted.com

View File

@ -88,7 +88,7 @@ GITEA_PV_NODE_SELECTOR_KEY="${GITEA_PV_NODE_SELECTOR_KEY:-${NODE_SELECTOR_KEY}}"
GITEA_PV_NODE="${GITEA_PV_NODE:-${GITEA_NODE_SELECTOR:-}}" GITEA_PV_NODE="${GITEA_PV_NODE:-${GITEA_NODE_SELECTOR:-}}"
GITEA_PV_BASE_DIR="${GITEA_PV_BASE_DIR:-/synology/d005}" GITEA_PV_BASE_DIR="${GITEA_PV_BASE_DIR:-/synology/d005}"
GITEA_STORAGE_CLASS="${GITEA_STORAGE_CLASS:-gitea-local-d005}" GITEA_STORAGE_CLASS="${GITEA_STORAGE_CLASS:-gitea-local-d005}"
GITEA_DOMAIN="${GITEA_DOMAIN:-git.knoe.org}" GITEA_DOMAIN="${GITEA_DOMAIN:-git.prole.org}"
GITEA_SSH_DOMAIN="${GITEA_SSH_DOMAIN:-$GITEA_DOMAIN}" GITEA_SSH_DOMAIN="${GITEA_SSH_DOMAIN:-$GITEA_DOMAIN}"
KNOE_DB_NAMESPACE="${KNOE_DB_NAMESPACE:-${DATABASE_NAMESPACE:-knoe-db}}" KNOE_DB_NAMESPACE="${KNOE_DB_NAMESPACE:-${DATABASE_NAMESPACE:-knoe-db}}"
KNOE_DB_CLUSTER="${KNOE_DB_CLUSTER:-${CLUSTER_NAME:-knoe-db}}" KNOE_DB_CLUSTER="${KNOE_DB_CLUSTER:-${CLUSTER_NAME:-knoe-db}}"

View File

@ -1699,9 +1699,9 @@ check_gitlab_post_apply_blocked() {
log "Desired Gitaly state (from CR): nodeSelector=[${desired_gitaly_node_selector}], rendered.global.persistence.storageClass=[${rendered_global_sc:-<unset>}], rendered.gitlab.gitaly.persistence.storageClass=[${rendered_gitlab_gitaly_sc:-<unset>}], rendered.gitaly.persistence.storageClass=[${rendered_chart_gitaly_sc:-<unset>}]" log "Desired Gitaly state (from CR): nodeSelector=[${desired_gitaly_node_selector}], rendered.global.persistence.storageClass=[${rendered_global_sc:-<unset>}], rendered.gitlab.gitaly.persistence.storageClass=[${rendered_gitlab_gitaly_sc:-<unset>}], rendered.gitaly.persistence.storageClass=[${rendered_chart_gitaly_sc:-<unset>}]"
log "Internal script GITALY_STORAGE_CLASS=[${GITALY_STORAGE_CLASS:-}]" log "Internal script GITALY_STORAGE_CLASS=[${GITALY_STORAGE_CLASS:-}]"
if [[ "$desired_cr" == *"gandalf.knoe.org"* ]]; then if [[ "$desired_cr" == *"gandalf.prole.org"* ]]; then
repair_blocked "GitLab CR contains legacy nodeSelector" \ repair_blocked "GitLab CR contains legacy nodeSelector" \
"Desired GitLab CR still contains gandalf.knoe.org. This is a configuration bug." "Desired GitLab CR still contains gandalf.prole.org. This is a configuration bug."
fi fi
if [[ "$desired_cr" == *"gitlab-gitaly-static"* ]]; then if [[ "$desired_cr" == *"gitlab-gitaly-static"* ]]; then
repair_blocked "GitLab CR contains legacy storageClass" \ repair_blocked "GitLab CR contains legacy storageClass" \
@ -1733,7 +1733,7 @@ check_gitlab_post_apply_blocked() {
live_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true) live_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true)
if [[ -z "$live_sts_yaml" ]]; then if [[ -z "$live_sts_yaml" ]]; then
log "Gitaly StatefulSet not found (awaiting operator action)..." log "Gitaly StatefulSet not found (awaiting operator action)..."
elif [[ "$live_sts_yaml" != *"gandalf.knoe.org"* && "$live_sts_yaml" != *"gitlab-gitaly-static"* ]]; then elif [[ "$live_sts_yaml" != *"gandalf.prole.org"* && "$live_sts_yaml" != *"gitlab-gitaly-static"* ]]; then
log "Gitaly StatefulSet converged to corrected state (clean nodeSelector/storageClass)." log "Gitaly StatefulSet converged to corrected state (clean nodeSelector/storageClass)."
local _live_sc local _live_sc
_live_sc=$(echo "$live_sts_yaml" | grep "storageClassName:" | cut -d: -f2 | xargs || true) _live_sc=$(echo "$live_sts_yaml" | grep "storageClassName:" | cut -d: -f2 | xargs || true)
@ -1760,7 +1760,7 @@ check_gitlab_post_apply_blocked() {
if [[ "$converged" == "0" ]]; then if [[ "$converged" == "0" ]]; then
local live_sts_yaml local live_sts_yaml
live_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true) live_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true)
if [[ -n "$live_sts_yaml" && ( "$live_sts_yaml" == *"gandalf.knoe.org"* || "$live_sts_yaml" == *"gitlab-gitaly-static"* ) ]]; then if [[ -n "$live_sts_yaml" && ( "$live_sts_yaml" == *"gandalf.prole.org"* || "$live_sts_yaml" == *"gitlab-gitaly-static"* ) ]]; then
log "Live StatefulSet still legacy after grace period. Performing explicit replacement..." log "Live StatefulSet still legacy after grace period. Performing explicit replacement..."
# We already confirmed desired CR is corrected at the start of this function. # We already confirmed desired CR is corrected at the start of this function.
kubectl -n "$NAMESPACE" delete statefulset "$gitaly_sts_name" --wait=true 2>/dev/null || true kubectl -n "$NAMESPACE" delete statefulset "$gitaly_sts_name" --wait=true 2>/dev/null || true
@ -1912,9 +1912,9 @@ check_gitlab_post_apply_blocked() {
cleanup_gitlab_wrong_gitaly_template_storage "${GITALY_STORAGE_CLASS:-}" "$live_gitaly_repo_data_template_sc" "$gitaly_pvc_mismatches" "$desired_cr" cleanup_gitlab_wrong_gitaly_template_storage "${GITALY_STORAGE_CLASS:-}" "$live_gitaly_repo_data_template_sc" "$gitaly_pvc_mismatches" "$desired_cr"
fi fi
if [[ "$gitaly_sts_yaml" == *"gandalf.knoe.org"* ]]; then if [[ "$gitaly_sts_yaml" == *"gandalf.prole.org"* ]]; then
repair_blocked "Gitaly using legacy Synology storage" \ repair_blocked "Gitaly using legacy Synology storage" \
"Resource: statefulset/${gitaly_sts_name}. Value: nodeSelector contains gandalf.knoe.org. Fix: Ensure GITLAB_STORAGE_NODE is not set in k8s mode." "Resource: statefulset/${gitaly_sts_name}. Value: nodeSelector contains gandalf.prole.org. Fix: Ensure GITLAB_STORAGE_NODE is not set in k8s mode."
fi fi
if [[ "$gitaly_sts_yaml" == *"gitlab-gitaly-static"* ]]; then if [[ "$gitaly_sts_yaml" == *"gitlab-gitaly-static"* ]]; then
repair_blocked "Gitaly using legacy Synology storage" \ repair_blocked "Gitaly using legacy Synology storage" \
@ -2380,7 +2380,7 @@ REDIS_HOST="${GITLAB_REDIS_HOST:-redis-master.${REDIS_NAMESPACE}.svc.cluster.loc
REDIS_PORT="${GITLAB_REDIS_PORT:-6379}" REDIS_PORT="${GITLAB_REDIS_PORT:-6379}"
# GitLab public domain (configurable; default follows deployment mode) # GitLab public domain (configurable; default follows deployment mode)
_default_gitlab_domain="git.knoe.org" _default_gitlab_domain="git.prole.org"
if [[ "$MODE" == "k8s" ]]; then if [[ "$MODE" == "k8s" ]]; then
_default_gitlab_domain="git.knoe.dev" _default_gitlab_domain="git.knoe.dev"
fi fi
@ -2418,7 +2418,7 @@ PRIMARY_GITLAB_HOST="${GITLAB_PUBLIC_HOSTS[0]}"
GITLAB_DOMAIN="$PRIMARY_GITLAB_HOST" GITLAB_DOMAIN="$PRIMARY_GITLAB_HOST"
PRIMARY_GITLAB_DOMAIN_ROOT="${PRIMARY_GITLAB_HOST#*.}" PRIMARY_GITLAB_DOMAIN_ROOT="${PRIMARY_GITLAB_HOST#*.}"
if [[ "$PRIMARY_GITLAB_DOMAIN_ROOT" == "$PRIMARY_GITLAB_HOST" || -z "$PRIMARY_GITLAB_DOMAIN_ROOT" ]]; then if [[ "$PRIMARY_GITLAB_DOMAIN_ROOT" == "$PRIMARY_GITLAB_HOST" || -z "$PRIMARY_GITLAB_DOMAIN_ROOT" ]]; then
PRIMARY_GITLAB_DOMAIN_ROOT="knoe.org" PRIMARY_GITLAB_DOMAIN_ROOT="prole.org"
fi fi
# Public ingress class (k8s/GKE defaults to gce; local clusters keep kong) # Public ingress class (k8s/GKE defaults to gce; local clusters keep kong)
@ -2513,7 +2513,7 @@ fi
# Requires k8s secret 'gitlab-google-oidc' in GITLAB_NAMESPACE with Google # Requires k8s secret 'gitlab-google-oidc' in GITLAB_NAMESPACE with Google
# OAuth2 client credentials (client_id, client_secret, redirect_uri). # OAuth2 client credentials (client_id, client_secret, redirect_uri).
# To disable OIDC: unset FRONTDOOR_HOST before running. # To disable OIDC: unset FRONTDOOR_HOST before running.
_default_auth_hostname="api.knoe.org" _default_auth_hostname="api.prole.org"
if [[ "$MODE" == "k8s" ]]; then if [[ "$MODE" == "k8s" ]]; then
_default_auth_hostname="api.knoe.dev" _default_auth_hostname="api.knoe.dev"
fi fi
@ -2891,7 +2891,7 @@ setup_knoe_db_for_gitlab() {
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Clean up any pre-existing Gitea / git.knoe.org configurations # Clean up any pre-existing Gitea / git.prole.org configurations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
cleanup_gitea() { cleanup_gitea() {
log "Checking for pre-existing Gitea deployment to remove before GitLab install..." log "Checking for pre-existing Gitea deployment to remove before GitLab install..."
@ -2908,13 +2908,13 @@ cleanup_gitea() {
kubectl -n "$gitea_ns" delete deploy/gitea svc/gitea-http svc/gitea-ssh \ kubectl -n "$gitea_ns" delete deploy/gitea svc/gitea-http svc/gitea-ssh \
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
# Remove Kong routes/services registered for git.knoe.org (best-effort) # Remove Kong routes/services registered for git.prole.org (best-effort)
local kong_ns="${KONG_NAMESPACE:-${NAMESPACE:-kong}}" local kong_ns="${KONG_NAMESPACE:-${NAMESPACE:-kong}}"
for res_type in kongplugins kongingresses; do for res_type in kongplugins kongingresses; do
kubectl -n "$gitea_ns" delete "$res_type" --all >/dev/null 2>&1 || true kubectl -n "$gitea_ns" delete "$res_type" --all >/dev/null 2>&1 || true
done done
# Remove gitea namespace Ingress objects that route git.knoe.org # Remove gitea namespace Ingress objects that route git.prole.org
kubectl -n "$gitea_ns" delete ingress \ kubectl -n "$gitea_ns" delete ingress \
-l "app.kubernetes.io/name=gitea" >/dev/null 2>&1 || true -l "app.kubernetes.io/name=gitea" >/dev/null 2>&1 || true
kubectl -n "$gitea_ns" delete ingress \ kubectl -n "$gitea_ns" delete ingress \
@ -2945,7 +2945,7 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Always remove gitea if its helm release exists (non-destructive path) # Always remove gitea if its helm release exists (non-destructive path)
# gitea and gitlab both claim git.knoe.org; they cannot coexist. # gitea and gitlab both claim git.prole.org; they cannot coexist.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if helm -n "${GITEA_NAMESPACE:-gitea}" status gitea >/dev/null 2>&1; then if helm -n "${GITEA_NAMESPACE:-gitea}" status gitea >/dev/null 2>&1; then
warn "Gitea release detected — removing to free git.knoe.org for GitLab..." warn "Gitea release detected — removing to free git.knoe.org for GitLab..."
@ -3070,7 +3070,7 @@ fi
if [[ "$MODE" == "k8s" ]]; then if [[ "$MODE" == "k8s" ]]; then
# In k8s/GKE mode, we do NOT use static storage nodes or nodeSelectors. # In k8s/GKE mode, we do NOT use static storage nodes or nodeSelectors.
STORAGE_NODE="" STORAGE_NODE=""
if [[ "$NODE_SELECTOR" == *"gandalf.knoe.org"* ]]; then if [[ "$NODE_SELECTOR" == *"gandalf.prole.org"* ]]; then
warn "Stripping legacy nodeSelector '${NODE_SELECTOR}' in k8s mode." warn "Stripping legacy nodeSelector '${NODE_SELECTOR}' in k8s mode."
NODE_SELECTOR="" NODE_SELECTOR=""
fi fi
@ -3719,7 +3719,7 @@ setup_gitlab_storage() {
local gitaly_sts_yaml local gitaly_sts_yaml
gitaly_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true) gitaly_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true)
if [[ -n "$gitaly_sts_yaml" ]]; then if [[ -n "$gitaly_sts_yaml" ]]; then
if [[ "$gitaly_sts_yaml" == *"gandalf.knoe.org"* || "$gitaly_sts_yaml" == *"gitlab-gitaly-static"* ]]; then if [[ "$gitaly_sts_yaml" == *"gandalf.prole.org"* || "$gitaly_sts_yaml" == *"gitlab-gitaly-static"* ]]; then
has_legacy=1 has_legacy=1
fi fi
fi fi
@ -3743,7 +3743,7 @@ setup_gitlab_storage() {
# Give it a moment to process deletions # Give it a moment to process deletions
sleep 2 sleep 2
else else
repair_blocked "Legacy Gitaly PV/PVC still bound to gitlab-gitaly-static / gandalf.knoe.org" \ repair_blocked "Legacy Gitaly PV/PVC still bound to gitlab-gitaly-static / gandalf.prole.org" \
"Set GITLAB_REPAIR_BLOCKED_AUTOCLEAN=1 to automatically repair by deleting legacy PVC/PV, or run: "Set GITLAB_REPAIR_BLOCKED_AUTOCLEAN=1 to automatically repair by deleting legacy PVC/PV, or run:
kubectl -n $NAMESPACE scale sts ${GITLAB_RELEASE}-gitaly --replicas=0 kubectl -n $NAMESPACE scale sts ${GITLAB_RELEASE}-gitaly --replicas=0
kubectl -n $NAMESPACE delete pvc repo-data-gitlab-gitaly-0 kubectl -n $NAMESPACE delete pvc repo-data-gitlab-gitaly-0

File diff suppressed because it is too large Load Diff

View File

@ -7,14 +7,14 @@ set -euo pipefail
# - Provision Kerberos principals and database accounts for knoe-system users # - Provision Kerberos principals and database accounts for knoe-system users
# - Creates: admin@PROLE.LOCAL (master password), guest@PROLE.LOCAL (read-only), # - Creates: admin@PROLE.LOCAL (master password), guest@PROLE.LOCAL (read-only),
# postgres service principal (keytab for GSS auth), developer group role # postgres service principal (keytab for GSS auth), developer group role
# - Cross-realm trust with myrddin.knoe.org PROLE.ORG is activated via # - Cross-realm trust with myrddin.prole.org PROLE.ORG is activated via
# PROLE_KDC_TRUST_REALM=PROLE.ORG in init_kdc.sh / init_knoe_auth.sh # PROLE_KDC_TRUST_REALM=PROLE.ORG in init_kdc.sh / init_knoe_auth.sh
# - Sets up service admin access: ArgoCD RBAC, Gitea, GitLab # - Sets up service admin access: ArgoCD RBAC, Gitea, GitLab
# #
# Prerequisites: # Prerequisites:
# init_kdc.sh initialize (with PROLE_KDC_REALM=PROLE.LOCAL, # init_kdc.sh initialize (with PROLE_KDC_REALM=PROLE.LOCAL,
# PROLE_KDC_TRUST_REALM=PROLE.ORG, # PROLE_KDC_TRUST_REALM=PROLE.ORG,
# KRB5_KDC=myrddin.knoe.org) # KRB5_KDC=myrddin.prole.org)
# init_cnpg_backup.sh (CNPG cluster must exist) # init_cnpg_backup.sh (CNPG cluster must exist)
# init_argocd.sh (ArgoCD must be running) # init_argocd.sh (ArgoCD must be running)
# #
@ -93,8 +93,8 @@ KNOE_DEPLOYMENT_MODE="${KNOE_DEPLOYMENT_MODE:-$(resolve_knoe_mode)}"
KNOE_AUTH_DEPLOYMENT="${KNOE_AUTH_DEPLOYMENT:-${PROLE_KDC_NAME:-$(default_knoe_auth_deployment)}}" KNOE_AUTH_DEPLOYMENT="${KNOE_AUTH_DEPLOYMENT:-${PROLE_KDC_NAME:-$(default_knoe_auth_deployment)}}"
KNOE_ADMIN_PRINCIPAL="${KNOE_ADMIN_PRINCIPAL:-admin}" KNOE_ADMIN_PRINCIPAL="${KNOE_ADMIN_PRINCIPAL:-admin}"
PROLE_KDC_REALM="${PROLE_KDC_REALM:-PROLE.LOCAL}" PROLE_KDC_REALM="${PROLE_KDC_REALM:-PROLE.LOCAL}"
GITEA_HOST="${GITEA_HOST:-git.knoe.org}" GITEA_HOST="${GITEA_HOST:-git.prole.org}"
GITLAB_HOST="${GITLAB_HOST:-gitlab.knoe.org}" GITLAB_HOST="${GITLAB_HOST:-gitlab.prole.org}"
GITEA_NAMESPACE="${GITEA_NAMESPACE:-gitea}" GITEA_NAMESPACE="${GITEA_NAMESPACE:-gitea}"
GITLAB_NAMESPACE="${GITLAB_NAMESPACE:-gitlab}" GITLAB_NAMESPACE="${GITLAB_NAMESPACE:-gitlab}"
ARGOCD_NAMESPACE="${ARGOCD_NAMESPACE:-argocd}" ARGOCD_NAMESPACE="${ARGOCD_NAMESPACE:-argocd}"
@ -455,8 +455,8 @@ SQL
# 8. Create knoe.user schema tables and bootstrap users # 8. Create knoe.user schema tables and bootstrap users
if [[ -n "$primary" ]]; then if [[ -n "$primary" ]]; then
create_knoe_schema "$primary" create_knoe_schema "$primary"
provision_user "$primary" "chrisfu" "chrisfu@knoe.org" "Chris Fu" "admin" provision_user "$primary" "chrisfu" "chrisfu@prole.org" "Chris Fu" "admin"
provision_user "$primary" "ron" "ron@knoe.org" "Ron" "developer" provision_user "$primary" "ron" "ron@prole.org" "Ron" "developer"
else else
warn "CNPG primary not found — skipping knoe.user schema and user provisioning" warn "CNPG primary not found — skipping knoe.user schema and user provisioning"
fi fi
@ -473,7 +473,7 @@ SQL
log "=== Initialization complete ===" log "=== Initialization complete ==="
log "" log ""
log "Next steps:" log "Next steps:"
log " 1. On myrddin.knoe.org: add krbtgt/PROLE.LOCAL@PROLE.ORG trust principal" log " 1. On myrddin.prole.org: add krbtgt/PROLE.LOCAL@PROLE.ORG trust principal"
log " (samba-tool domain trust or equivalent, using trust_shared_password from knoe-kdc-secrets)" log " (samba-tool domain trust or equivalent, using trust_shared_password from knoe-kdc-secrets)"
log " 2. Confirm Grafana auth.proxy configured with: headers = Role:X-Knoe-Groups" log " 2. Confirm Grafana auth.proxy configured with: headers = Role:X-Knoe-Groups"
log " 3. After admin logs in to Gitea/GitLab for the first time, re-run: $0 initialize" log " 3. After admin logs in to Gitea/GitLab for the first time, re-run: $0 initialize"

View File

@ -137,9 +137,9 @@ if [[ "${KNOE_MODE:-}" == "k8s" ]]; then
else else
KONG_NAME="${KONG_NAME:-knoe-svc-kong}" KONG_NAME="${KONG_NAME:-knoe-svc-kong}"
KONG_CONFIG_NAME="${KONG_CONFIG_NAME:-knoe-svc-kong-config}" KONG_CONFIG_NAME="${KONG_CONFIG_NAME:-knoe-svc-kong-config}"
SERVICE_HOSTNAME="${SERVICE_HOSTNAME:-svc.knoe.org}" SERVICE_HOSTNAME="${SERVICE_HOSTNAME:-svc.prole.org}"
AUTH_HOSTNAME="${AUTH_HOSTNAME:-api.knoe.org}" AUTH_HOSTNAME="${AUTH_HOSTNAME:-api.prole.org}"
GITEA_HOSTNAME="${GITEA_HOSTNAME:-${GITEA_DOMAIN:-git.knoe.org}}" GITEA_HOSTNAME="${GITEA_HOSTNAME:-${GITEA_DOMAIN:-git.prole.org}}"
SERVICE_INGRESS_TLS_ENABLED="${SERVICE_INGRESS_TLS_ENABLED:-1}" SERVICE_INGRESS_TLS_ENABLED="${SERVICE_INGRESS_TLS_ENABLED:-1}"
fi fi
KONG_PROXY_PORT="${KONG_PROXY_PORT:-8000}" KONG_PROXY_PORT="${KONG_PROXY_PORT:-8000}"

View File

@ -198,7 +198,7 @@ validate_monitoring_storage_classes() {
} }
monitoring_nodes_available() { monitoring_nodes_available() {
kubectl get nodes -l "knoe.org/node-role=general" -o name 2>/dev/null | grep -q . kubectl get nodes -l "prole.org/node-role=general" -o name 2>/dev/null | grep -q .
} }
resolve_monitoring_primary_node() { resolve_monitoring_primary_node() {
@ -207,11 +207,11 @@ resolve_monitoring_primary_node() {
printf '%s' "${MONITORING_PRIMARY_NODE}" printf '%s' "${MONITORING_PRIMARY_NODE}"
return 0 return 0
fi fi
if kubectl get node merlin.knoe.org >/dev/null 2>&1; then if kubectl get node merlin.prole.org >/dev/null 2>&1; then
printf '%s' "merlin.knoe.org" printf '%s' "merlin.prole.org"
return 0 return 0
fi fi
node=$(kubectl get nodes -l "knoe.org/node-role=general" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) node=$(kubectl get nodes -l "prole.org/node-role=general" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [[ -n "${node:-}" ]]; then if [[ -n "${node:-}" ]]; then
printf '%s' "$node" printf '%s' "$node"
return 0 return 0
@ -461,7 +461,7 @@ render_node_selector() {
local indent="$1" local indent="$1"
cat <<EOF cat <<EOF
${indent}nodeSelector: ${indent}nodeSelector:
${indent} knoe.org/node-role: general ${indent} prole.org/node-role: general
EOF EOF
} }
@ -492,7 +492,7 @@ render_tolerations() {
mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-}}" mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-}}"
local key value effect local key value effect
key="${MONITORING_TAINT_KEY:-knoe.org/monitoring}" key="${MONITORING_TAINT_KEY:-prole.org/monitoring}"
value="${MONITORING_TAINT_VALUE:-true}" value="${MONITORING_TAINT_VALUE:-true}"
effect="${MONITORING_TAINT_EFFECT:-NoSchedule}" effect="${MONITORING_TAINT_EFFECT:-NoSchedule}"
@ -515,7 +515,7 @@ ${indent} nodeAffinity:
${indent} requiredDuringSchedulingIgnoredDuringExecution: ${indent} requiredDuringSchedulingIgnoredDuringExecution:
${indent} nodeSelectorTerms: ${indent} nodeSelectorTerms:
${indent} - matchExpressions: ${indent} - matchExpressions:
${indent} - key: knoe.org/node-role ${indent} - key: prole.org/node-role
${indent} operator: In ${indent} operator: In
${indent} values: ${indent} values:
${indent} - general ${indent} - general
@ -1293,7 +1293,7 @@ $(render_tolerations " ")
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: NotIn operator: NotIn
values: values:
- pi.knoe.org - pi.prole.org
EOF EOF
local monitoring_helm_server_side local monitoring_helm_server_side

View File

@ -189,7 +189,7 @@ resolve_registry_node_selector() {
return 0 return 0
fi fi
# Default: pin to the control-plane node so hostPort:5000 is reachable via # Default: pin to the control-plane node so hostPort:5000 is reachable via
# the k3s server host (e.g. myrddin.knoe.org:5000). # the k3s server host (e.g. myrddin.prole.org:5000).
resolve_control_plane_node_selector resolve_control_plane_node_selector
} }

View File

@ -11,7 +11,7 @@ set -euo pipefail
: "${K3D_REGISTRY_CONFIG:=}" # optional path to registries.yaml : "${K3D_REGISTRY_CONFIG:=}" # optional path to registries.yaml
# Remote server details # Remote server details
: "${K3S_REMOTE_SERVER_HOST:=raspberry.knoe.org}" : "${K3S_REMOTE_SERVER_HOST:=raspberry.prole.org}"
: "${K3S_REMOTE_SERVER_PORT:=6443}" : "${K3S_REMOTE_SERVER_PORT:=6443}"
: "${K3S_REMOTE_TOKEN:=}" # required : "${K3S_REMOTE_TOKEN:=}" # required

View File

@ -10,7 +10,7 @@
set -euo pipefail set -euo pipefail
# Configurable via env vars; provide sensible defaults # Configurable via env vars; provide sensible defaults
: "${K3S_FIRST_SERVER_HOST:=raspberry.knoe.org}" : "${K3S_FIRST_SERVER_HOST:=raspberry.prole.org}"
: "${K3S_FIRST_SERVER_USER:=pi}" : "${K3S_FIRST_SERVER_USER:=pi}"
: "${K3S_SSH_KEY:=$HOME/.ssh/id_rsa}" : "${K3S_SSH_KEY:=$HOME/.ssh/id_rsa}"
: "${K3S_VERSION:=}" : "${K3S_VERSION:=}"

View File

@ -10,8 +10,8 @@ fi
set -euo pipefail set -euo pipefail
SERVER=${K3S_SERVER:-"https://myrddin.knoe.org:6443"} SERVER=${K3S_SERVER:-"https://myrddin.prole.org:6443"}
REMOTE_HOST=${K3S_REMOTE_HOST:-"myrddin.knoe.org"} REMOTE_HOST=${K3S_REMOTE_HOST:-"myrddin.prole.org"}
REMOTE_USER=${K3S_REMOTE_USER:-"ansible"} REMOTE_USER=${K3S_REMOTE_USER:-"ansible"}
SSH_KEY=${K3S_SSH_KEY:-"$HOME/.ssh/id_ed25519_ansible"} SSH_KEY=${K3S_SSH_KEY:-"$HOME/.ssh/id_ed25519_ansible"}
REMOTE_KUBECONFIG=${K3S_REMOTE_KUBECONFIG:-"/etc/rancher/k3s/k3s.yaml"} REMOTE_KUBECONFIG=${K3S_REMOTE_KUBECONFIG:-"/etc/rancher/k3s/k3s.yaml"}

View File

@ -7,7 +7,7 @@ data:
kube_config.sh: | kube_config.sh: |
#!/bin/bash #!/bin/bash
# k3s/kube_config.sh - Automatically configure knoe-k3s context # k3s/kube_config.sh - Automatically configure knoe-k3s context
SERVER="https://myrddin.knoe.org:6443" SERVER="https://myrddin.prole.org:6443"
CLUSTER="knoe-k3s" CLUSTER="knoe-k3s"
USER="knoe-k3s" USER="knoe-k3s"
CONTEXT="knoe-k3s" CONTEXT="knoe-k3s"
@ -109,7 +109,7 @@ metadata:
kubernetes.io/ingress.class: traefik kubernetes.io/ingress.class: traefik
spec: spec:
rules: rules:
- host: myrddin.knoe.org - host: myrddin.prole.org
http: http:
paths: paths:
- path: /k3s/kube_config.sh - path: /k3s/kube_config.sh

View File

@ -17,5 +17,5 @@ data:
} }
[domain_realm] [domain_realm]
.knoe.org = PROLE.ORG .prole.org = PROLE.ORG
knoe.org = PROLE.ORG prole.org = PROLE.ORG

View File

@ -7,4 +7,9 @@
<excludeFolder url="file://$MODULE_DIR$/deploy/opentofu/k3s/.terraform" /> <excludeFolder url="file://$MODULE_DIR$/deploy/opentofu/k3s/.terraform" />
</content> </content>
</component> </component>
<component name="FacetManager">
<facet type="Python" name="Python">
<configuration sdkName="Python 3.14 (knoe-db)" />
</facet>
</component>
</module> </module>

View File

@ -814,7 +814,7 @@ def _detect_ansible_storage_mounts(inventory_path: Path) -> dict[str, dict[str,
Returns a mapping like: Returns a mapping like:
{ {
"d001": {"host": "myrddin.knoe.org", "path": "/synology/d001"}, "d001": {"host": "myrddin.prole.org", "path": "/synology/d001"},
... ...
} }

View File

@ -836,7 +836,7 @@ class DockerBuildMilestone(Milestone):
registry = (state.config_data.get("Global", {}) or {}).get("KNOE_IMAGE_REGISTRY", "").strip() registry = (state.config_data.get("Global", {}) or {}).get("KNOE_IMAGE_REGISTRY", "").strip()
if not registry or registry == "localhost:5000": if not registry or registry == "localhost:5000":
server = state.inputs.get("init_cluster.k3s_server_url", "myrddin.knoe.org") server = state.inputs.get("init_cluster.k3s_server_url", "myrddin.prole.org")
if "://" in server: if "://" in server:
server = server.split("://")[1] server = server.split("://")[1]
if ":" in server: if ":" in server:

View File

@ -227,7 +227,7 @@ def _values_yaml_k3s(grafana_password: str, env: dict | None) -> str:
sc_alert = f"merlin-local-iscsi-{volume_id}-alertmanager" sc_alert = f"merlin-local-iscsi-{volume_id}-alertmanager"
sc_grafana = f"merlin-local-iscsi-{volume_id}-grafana" sc_grafana = f"merlin-local-iscsi-{volume_id}-grafana"
monitoring_node = str((env or {}).get("MONITORING_PRIMARY_NODE") or "merlin.knoe.org") monitoring_node = str((env or {}).get("MONITORING_PRIMARY_NODE") or "merlin.prole.org")
excluded_nodes = str((env or {}).get("MONITORING_NODE_EXPORTER_EXCLUDE_NODES") or "pi.knoe.org") excluded_nodes = str((env or {}).get("MONITORING_NODE_EXPORTER_EXCLUDE_NODES") or "pi.knoe.org")
excluded_list = [n.strip() for n in excluded_nodes.split(",") if n.strip()] excluded_list = [n.strip() for n in excluded_nodes.split(",") if n.strip()]

View File

@ -390,6 +390,8 @@ class KnoeInstaller(
) )
self.project_root = PROJECT_ROOT self.project_root = PROJECT_ROOT
self.cfg_path = resolved_cfg self.cfg_path = resolved_cfg
from knoe.core.env import DEFAULT_ACTION_FLAGS
self._action_flags: dict[str, bool] = DEFAULT_ACTION_FLAGS.copy()
self._init_shared_state() self._init_shared_state()
self._init_database_options_state() self._init_database_options_state()
self.screens = None self.screens = None
@ -525,7 +527,7 @@ class KnoeInstaller(
] ]
self.nav_widgets = {} self.nav_widgets = {}
self._mode_tab_widgets: dict = {} self._mode_tab_widgets: dict = {}
self.deployment_mode = tk.StringVar(value=__import__("os").environ.get("KNOE_MODE", "k3s")) self.deployment_mode = tk.StringVar(value=__import__("os").environ.get("KNOE_MODE", ""))
self._create_sidebar_nav() self._create_sidebar_nav()
# Validation attributes # Validation attributes

View File

@ -262,44 +262,23 @@ class NavigationMixin:
except Exception: except Exception:
pass pass
_MODE_COLORS = {"k3d": "#4A90D9", "k3s": "#27AE60", "k8s": "#E67E22"} _MODE_COLORS = {"min": "#8E44AD", "k3d": "#4A90D9", "k3s": "#27AE60", "gke": "#E67E22"}
def _set_deployment_mode(self, mode: str) -> None: def _set_deployment_mode(self, mode: str) -> None:
"""Switch the active deployment mode and persist it to the environment.""" """Switch the active deployment mode and persist it to the environment."""
self.deployment_mode.set(mode) self.deployment_mode.set(mode)
os.environ["KNOE_MODE"] = mode os.environ["KNOE_MODE"] = mode
self._refresh_mode_tabs()
def _refresh_mode_tabs(self) -> None: def _refresh_mode_tabs(self) -> None:
"""Update tab highlight colours to reflect the active deployment mode.""" """No-op: mode tabs replaced by welcome screen card selector."""
active = self.deployment_mode.get() pass
for value, widget in self._mode_tab_widgets.items():
if value == active: def _is_min_mode(self) -> bool:
widget.configure(bg=self._MODE_COLORS.get(value, "#888"), fg="white") """Return True when the user has selected minimal (containerd-only) mode."""
else: return self.deployment_mode.get() == "min"
widget.configure(bg="#D5D5C5", fg="#555")
def _create_sidebar_nav(self): def _create_sidebar_nav(self):
"""Create the left-hand navigation menu.""" """Create the left-hand navigation menu."""
# ── Deployment mode tab strip ────────────────────────────────────
mode_frame = tk.Frame(self.sidebar, bg="#F5F5DC")
mode_frame.pack(fill="x", padx=12, pady=(14, 4))
for label, value in [("k3d", "k3d"), ("k3s", "k3s"), ("k8s", "k8s")]:
btn = tk.Label(
mode_frame,
text=label,
bg="#D5D5C5",
fg="#555",
font=("SF Pro Text", 9, "bold"),
padx=8,
pady=3,
cursor="hand2",
)
btn.pack(side="left", padx=2)
btn.bind("<Button-1>", lambda e, v=value: self._set_deployment_mode(v))
self._mode_tab_widgets[value] = btn
self._refresh_mode_tabs()
# ── INSTALLER label ────────────────────────────────────────────── # ── INSTALLER label ──────────────────────────────────────────────
tk.Label( tk.Label(
self.sidebar, self.sidebar,
@ -375,7 +354,10 @@ class NavigationMixin:
self.show_page(seq[-1] if seq else "deps_summary") self.show_page(seq[-1] if seq else "deps_summary")
return return
if current_id == "env_setup": if current_id == "env_setup":
self.show_page("network_scan") if self._is_min_mode():
self.show_page("deps_summary")
else:
self.show_page("network_scan")
return return
if current_id == "init_cluster": if current_id == "init_cluster":
if getattr(self, "_showing_service_overlay", False): if getattr(self, "_showing_service_overlay", False):
@ -397,7 +379,10 @@ class NavigationMixin:
self.show_page("common_services") self.show_page("common_services")
return return
if current_id == "init_password": if current_id == "init_password":
self.show_page("init_db_build") if self._is_min_mode():
self.show_page("env_setup")
else:
self.show_page("init_db_build")
return return
if current_id == "init_db_build": if current_id == "init_db_build":
self.show_page("database_options") self.show_page("database_options")
@ -426,6 +411,10 @@ class NavigationMixin:
if current_id == "init_cnpg_deploy": if current_id == "init_cnpg_deploy":
self.show_page("supabase_config") self.show_page("supabase_config")
return return
if current_id == "security":
if self._is_min_mode():
self.show_page("init_scripts")
return
if current_id == "create_installer": if current_id == "create_installer":
self.show_page("init_cnpg_deploy") self.show_page("init_cnpg_deploy")
return return
@ -492,6 +481,9 @@ class NavigationMixin:
return return
if current_id == "network_scan": if current_id == "network_scan":
if self._is_min_mode():
self.show_page("env_setup")
return
# Capture network scan info # Capture network scan info
try: try:
self.knoe_cfg_data["Network"][ self.knoe_cfg_data["Network"][
@ -593,7 +585,10 @@ class NavigationMixin:
except Exception: except Exception:
pass pass
return return
self.show_page("init_cluster") if self._is_min_mode():
self.show_page("init_password")
else:
self.show_page("init_cluster")
return return
if current_id == "init_password": if current_id == "init_password":
@ -678,7 +673,9 @@ class NavigationMixin:
"Completed" if getattr(self, "_scripts_success", False) else "Attempted" "Completed" if getattr(self, "_scripts_success", False) else "Attempted"
) )
self._save_knoe_cfg() self._save_knoe_cfg()
if self.kerberos_enabled.get(): if self._is_min_mode():
self.show_page("security")
elif self.kerberos_enabled.get():
self.show_page("kerberos_config") self.show_page("kerberos_config")
else: else:
self.show_page("argocd_config") self.show_page("argocd_config")
@ -885,7 +882,12 @@ class NavigationMixin:
# Page-specific adjustments # Page-specific adjustments
pid = self.pages[self.page_index][0] pid = self.pages[self.page_index][0]
if pid == "build": if pid == "welcome":
if not self.deployment_mode.get():
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
elif pid == "build":
# Build page: show Build or Next depending on state # Build page: show Build or Next depending on state
if getattr(self, "_built_success", False): if getattr(self, "_built_success", False):
self.next_button.configure(text="Next") self.next_button.configure(text="Next")

View File

@ -1,68 +1,198 @@
"""Welcome / splash screen.""" """Welcome / splash screen."""
import os
import threading import threading
import time import time
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox, filedialog from tkinter import ttk, messagebox, filedialog
from knoe import screen as ui from knoe import screen as ui
_WELCOME_CARDS = [
{
"mode": "min",
"title": "Just a Database",
"body": "One knoe-db container via containerd.\nNo Kubernetes needed. Perfect for\ndeveloping a Spring app locally.",
"badge": "Homebrew + 1Password",
"color": "#8E44AD",
},
{
"mode": "k3d",
"title": "Local Cluster",
"body": "k3s-in-Docker cluster on your Mac.\nFull CNPG database — add Supabase,\nArgoCD, Gitea or GitLab.",
"badge": "Docker + Homebrew + 1Password",
"color": "#4A90D9",
},
{
"mode": "k3s",
"title": "Homelab",
"body": "Multi-node k3s on real hardware.\nFull Kerberos auth stack, Garage S3\nand monitoring.",
"badge": "k3sup + Homebrew + 1Password",
"color": "#27AE60",
},
{
"mode": "gke",
"title": "Production",
"body": "Dual GKE clusters on Google Cloud.\nCNPG + GCS backups and Workload\nIdentity.",
"badge": "gcloud + 1Password",
"color": "#E67E22",
},
]
_MODE_TO_CLUSTER_ENV = {"min": "min", "k3d": "dev", "k3s": "service", "gke": "prod"}
_MODE_NEEDS = {
"min": "You'll need: Homebrew + 1Password",
"k3d": "You'll need: Docker, Homebrew + 1Password",
"k3s": "You'll need: k3sup, Homebrew + 1Password",
"gke": "You'll need: gcloud CLI + 1Password",
}
class WelcomeScreenMixin: class WelcomeScreenMixin:
"""Welcome / splash screen.""" """Welcome / splash screen."""
def _render_welcome_page(self): def _render_welcome_page(self):
# Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975
content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx
right_margin = content_width - 48 right_margin = content_width - 48
ui.canvas_text( # Letterhead
self, ui.canvas_text(self, right_margin, 40, "knoe.dev",
right_margin, fill="#6e6e73", font=("SF Pro Text", 32, "bold"), anchor="ne")
40, ui.canvas_text(self, right_margin, 85, "infrastructure.auto()",
"knoe.dev", fill="#6e6e73", font=("SF Pro Text", 18), anchor="ne")
fill="#6e6e73",
font=("SF Pro Text", 32, "bold"),
anchor="ne",
)
ui.canvas_text(
self,
right_margin,
85,
"infrastructure.auto()",
fill="#6e6e73",
font=("SF Pro Text", 18),
anchor="ne",
)
# Welcome title self._render_title("Welcome to Knoe.DB", y=145)
self._render_title("Welcome", y=150)
welcome_text = ( intro = (
"This installer will guide you through the process of setting up the Knoe Database and its supporting " "Knoe.DB Installer sets up a production-grade PostgreSQL cluster with Kerberos "
"infrastructure. We have designed this process to be as automated as possible, ensuring that your " "authentication.\nPick the mode that matches your hardware — we'll walk you through every step."
"deployment is secure, efficient, and tailored to your specific network environment.\n\n"
"What to expect:\n"
"• Network Environment Discovery: We'll scan for existing services like Active Directory and DNS.\n"
"• System Configuration: Setting up local paths and environment variables.\n"
"• Dependency Management: Ensuring all required tools (Docker, k3d, etc.) are ready.\n"
"• Database Initialization: Configuring passwords, Kerberos authentication, and deploying the database cluster.\n\n"
"We are excited to have you join our community and start building with us. "
"Welcome to the neighborhood! Let's get started by preparing your system for the Knoe experience."
) )
ui.canvas_text(self, 48, 200, intro,
fill="#1d1d1f", font=("SF Pro Text", 13), width=800)
ui.canvas_text( # Mode selector cards
self, card_w = 185
48, card_h = 200
220, card_gap = 18
welcome_text, card_x0 = 48
fill="black", card_y0 = 265
font=("SF Pro Text", 13),
width=750, self._welcome_card_rects = {}
for i, card in enumerate(_WELCOME_CARDS):
x0 = card_x0 + i * (card_w + card_gap)
x1 = x0 + card_w
y0 = card_y0
y1 = card_y0 + card_h
bg = self.bg_canvas.create_rectangle(
x0, y0, x1, y1, fill="white", outline="#CCCCCC", width=2,
)
self._canvas_items.append(bg)
bar = self.bg_canvas.create_rectangle(
x0 + 1, y0 + 1, x1 - 1, y0 + 6, fill=card["color"], outline="",
)
self._canvas_items.append(bar)
mode_lbl = self.bg_canvas.create_text(
x0 + 14, y0 + 18, text=card["mode"].upper(),
fill=card["color"], font=("SF Pro Text", 9, "bold"), anchor="nw",
)
self._canvas_items.append(mode_lbl)
title_lbl = self.bg_canvas.create_text(
x0 + 14, y0 + 36, text=card["title"],
fill="#1d1d1f", font=("SF Pro Text", 13, "bold"), anchor="nw",
)
self._canvas_items.append(title_lbl)
body_lbl = self.bg_canvas.create_text(
x0 + 14, y0 + 62, text=card["body"],
fill="#555555", font=("SF Pro Text", 11), anchor="nw",
width=card_w - 28,
)
self._canvas_items.append(body_lbl)
badge_lbl = self.bg_canvas.create_text(
x0 + 14, y1 - 26, text=card["badge"],
fill=card["color"], font=("SF Pro Text", 9), anchor="nw",
width=card_w - 28,
)
self._canvas_items.append(badge_lbl)
self._welcome_card_rects[card["mode"]] = bg
mode = card["mode"]
for item_id in (bg, bar, mode_lbl, title_lbl, body_lbl, badge_lbl):
self.bg_canvas.tag_bind(
item_id, "<Button-1>",
lambda e, m=mode: self._on_welcome_card_click(m),
)
self.bg_canvas.tag_bind(
item_id, "<Enter>",
lambda e, r=bg, m=mode: self.bg_canvas.itemconfig(
r, outline=self._MODE_COLORS.get(m, "#4A90D9")
if self.deployment_mode.get() != m else None
),
)
self.bg_canvas.tag_bind(
item_id, "<Leave>",
lambda e, r=bg, m=mode: self.bg_canvas.itemconfig(
r, outline=self._MODE_COLORS.get(m, "#4A90D9")
if self.deployment_mode.get() == m else "#CCCCCC"
),
)
# "You'll need" status line
status_y = card_y0 + card_h + 20
self._splash_status_item = self.bg_canvas.create_text(
48, status_y,
text="← Select a mode above to continue",
fill="#8B8B7A", font=("SF Pro Text", 12, "italic"), anchor="nw",
) )
self._canvas_items.append(self._splash_status_item)
# Restore highlight if a mode was already chosen (e.g. loaded from config)
current = self.deployment_mode.get()
if current and current in self._welcome_card_rects:
self._highlight_welcome_card(current)
try:
self.bg_canvas.itemconfig(
self._splash_status_item,
text=_MODE_NEEDS.get(current, ""),
)
except Exception:
pass
# Ensure footer is updated (Next button visible)
self.update_footer() self.update_footer()
def _on_welcome_card_click(self, mode: str) -> None:
self.deployment_mode.set(mode)
os.environ["KNOE_MODE"] = mode
try:
self.cluster_env.set(_MODE_TO_CLUSTER_ENV.get(mode, "dev"))
except Exception:
pass
self._highlight_welcome_card(mode)
try:
if self._splash_status_item is not None:
self.bg_canvas.itemconfig(
self._splash_status_item,
text=_MODE_NEEDS.get(mode, ""),
)
except Exception:
pass
self.update_footer()
def _highlight_welcome_card(self, selected_mode: str) -> None:
for m, rect_id in getattr(self, "_welcome_card_rects", {}).items():
if m == selected_mode:
color = self._MODE_COLORS.get(m, "#4A90D9")
self.bg_canvas.itemconfig(rect_id, outline=color, width=3)
else:
self.bg_canvas.itemconfig(rect_id, outline="#CCCCCC", width=2)
def _start_welcome_dependency_scan(self): def _start_welcome_dependency_scan(self):
self.splash_scan_running = True self.splash_scan_running = True
self.splash_scan_done_at = None self.splash_scan_done_at = None

View File

@ -20,7 +20,9 @@
<java.version>24</java.version> <java.version>24</java.version>
<spring-modulith.version>1.0.0</spring-modulith.version> <spring-modulith.version>1.0.0</spring-modulith.version>
<skip.shellspec>false</skip.shellspec> <skip.shellspec>false</skip.shellspec>
<skip.authority>false</skip.authority> <!-- authority/ is now the standalone knoe-auth project (~/dev/knoe-auth).
Set to false temporarily if you need to run its tests from the knoe-db root. -->
<skip.authority>true</skip.authority>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>

36
pyproject.toml Normal file
View File

@ -0,0 +1,36 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-ra --tb=short"
markers = [
"unit: pure unit test — no external services, no cluster, runs anywhere",
"integration: requires a running service, cluster, or network dependency",
"min: tests the min (containerd) deployment path",
"k3d: tests the k3d (local Docker cluster) deployment path",
"k3s: tests the k3s (homelab) deployment path",
"gke: tests the GKE (production) deployment path",
]
[tool.coverage.run]
source = ["knoe"]
branch = true
omit = [
"tests/*",
"*/__init__.py",
"knoe/ui/*", # UI layer tested via integration only
]
[tool.coverage.report]
show_missing = true
skip_covered = false
# Raised incrementally as each pipeline phase lands — see docs/pipeline-phases.md Appendix C
fail_under = 0
[tool.coverage.xml]
output = "coverage.xml"
[tool.coverage.html]
directory = "htmlcov"

8
requirements-test.txt Normal file
View File

@ -0,0 +1,8 @@
# Test-only dependencies — install alongside requirements.txt in CI and local dev
# pip install -r requirements.txt -r requirements-test.txt
pytest>=7.4
pytest-cov>=4.1
pytest-mock>=3.11
coverage[toml]>=7.3
vulture>=2.10

View File

@ -1,17 +1,25 @@
#!/bin/bash #!/bin/bash
# Run all unit tests and generate a coverage report # Run all unit tests and generate a coverage report.
# Coverage config is in pyproject.toml [tool.coverage.*].
export PYTHONPATH=$PYTHONPATH:. set -euo pipefail
# Check if pytest-cov is installed export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}."
if pytest --trace-config | grep -q "pytest_cov"; then
if python -c "import pytest_cov" 2>/dev/null; then
echo "Running tests with coverage..." echo "Running tests with coverage..."
pytest --cov=knoe --cov=install --cov-report=term-missing --cov-report=html tests/ pytest \
--cov=knoe \
--cov-report=term-missing \
--cov-report=html \
--cov-report=xml \
tests/
RET=$? RET=$?
echo "Coverage report (HTML) generated in htmlcov/index.html" echo "HTML coverage report: htmlcov/index.html"
echo "XML coverage report: coverage.xml"
exit $RET exit $RET
else else
echo "Warning: pytest-cov not found. Running tests without coverage." echo "Warning: pytest-cov not found. Running tests without coverage."
echo "To enable coverage, install it via: pip install pytest-cov" echo "Install it with: pip install -r requirements-test.txt"
pytest tests/ pytest tests/
fi fi

View File

@ -2,14 +2,14 @@
set -euo pipefail set -euo pipefail
export KUBECONFIG=/Users/chrisfu/dev/knoe/knoe-k3s.kubeconfig export KUBECONFIG=/Users/chrisfu/dev/knoe/knoe-k3s.kubeconfig
echo "==> Creating /synology/d005/gitlab/{minio,gitaly} on gandalf.knoe.org..." echo "==> Creating /synology/d005/gitlab/{minio,gitaly} on gandalf.prole.org..."
kubectl -n gitlab run synology-mkdir \ kubectl -n gitlab run synology-mkdir \
--image=alpine:latest \ --image=alpine:latest \
--restart=Never \ --restart=Never \
--rm --attach \ --rm --attach \
--overrides='{ --overrides='{
"spec":{ "spec":{
"nodeName":"gandalf.knoe.org", "nodeName":"gandalf.prole.org",
"tolerations":[{"operator":"Exists"}], "tolerations":[{"operator":"Exists"}],
"volumes":[{"name":"synology","hostPath":{"path":"/synology/d005","type":"Directory"}}], "volumes":[{"name":"synology","hostPath":{"path":"/synology/d005","type":"Directory"}}],
"containers":[{ "containers":[{

View File

@ -26,7 +26,7 @@ spec:
- matchExpressions: - matchExpressions:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: [gandalf.knoe.org] values: [gandalf.prole.org]
PVYAML PVYAML
echo "==> Waiting 20s for gitaly PVC to bind..." echo "==> Waiting 20s for gitaly PVC to bind..."
@ -42,7 +42,7 @@ kubectl -n gitea run minio-init \
--image=quay.io/minio/mc:RELEASE.2022-10-20T23-30-35Z \ --image=quay.io/minio/mc:RELEASE.2022-10-20T23-30-35Z \
--restart=Never \ --restart=Never \
--overrides="{ --overrides="{
\"spec\":{\"nodeName\":\"gandalf.knoe.org\",\"tolerations\":[{\"operator\":\"Exists\"}], \"spec\":{\"nodeName\":\"gandalf.prole.org\",\"tolerations\":[{\"operator\":\"Exists\"}],
\"containers\":[{\"name\":\"minio-init\", \"containers\":[{\"name\":\"minio-init\",
\"image\":\"quay.io/minio/mc:RELEASE.2022-10-20T23-30-35Z\", \"image\":\"quay.io/minio/mc:RELEASE.2022-10-20T23-30-35Z\",
\"command\":[\"sh\",\"-c\", \"command\":[\"sh\",\"-c\",

View File

@ -30,7 +30,7 @@ spec:
- matchExpressions: - matchExpressions:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: [gandalf.knoe.org] values: [gandalf.prole.org]
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolume kind: PersistentVolume
@ -50,7 +50,7 @@ spec:
- matchExpressions: - matchExpressions:
- key: kubernetes.io/hostname - key: kubernetes.io/hostname
operator: In operator: In
values: [gandalf.knoe.org] values: [gandalf.prole.org]
PVYAML PVYAML
echo "==> Waiting 15s for binding..." echo "==> Waiting 15s for binding..."

View File

@ -25,8 +25,8 @@ export CLUSTER_NAME="knoe-db"
# Replicate milestones.py GitOpsMilestone.execute() lines 965-977 # Replicate milestones.py GitOpsMilestone.execute() lines 965-977
export GITLAB_NAMESPACE="gitlab" # line 971: env["GITLAB_NAMESPACE"] = ns (ns="gitlab") export GITLAB_NAMESPACE="gitlab" # line 971: env["GITLAB_NAMESPACE"] = ns (ns="gitlab")
export GITLAB_NODE_SELECTOR="gandalf.knoe.org" # line 976 export GITLAB_NODE_SELECTOR="gandalf.prole.org" # line 976
export NODE_SELECTOR="gandalf.knoe.org" # line 977 export NODE_SELECTOR="gandalf.prole.org" # line 977
echo " Pre-call env: NAMESPACE='$NAMESPACE' GITLAB_NAMESPACE='$GITLAB_NAMESPACE'" echo " Pre-call env: NAMESPACE='$NAMESPACE' GITLAB_NAMESPACE='$GITLAB_NAMESPACE'"
echo "" echo ""