mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
Merge remote-tracking branch 'knoe/main'
This commit is contained in:
commit
bb44074b1b
29
.idea/runConfigurations/pytest_all.xml
Normal file
29
.idea/runConfigurations/pytest_all.xml
Normal 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>
|
||||
24
.idea/runConfigurations/pytest_gke.xml
Normal file
24
.idea/runConfigurations/pytest_gke.xml
Normal 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>
|
||||
24
.idea/runConfigurations/pytest_k3d.xml
Normal file
24
.idea/runConfigurations/pytest_k3d.xml
Normal 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>
|
||||
24
.idea/runConfigurations/pytest_min.xml
Normal file
24
.idea/runConfigurations/pytest_min.xml
Normal 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>
|
||||
23
.idea/runConfigurations/pytest_unit.xml
Normal file
23
.idea/runConfigurations/pytest_unit.xml
Normal 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 "unit and not integration" --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>
|
||||
@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<a href="https://svc.knoe.org"><pre>
|
||||
<a href="https://svc.prole.org"><pre>
|
||||
# ###########################
|
||||
# ╭──────────────────────╮ #
|
||||
# │ _ │ #
|
||||
|
||||
@ -62,6 +62,39 @@
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 & 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 & 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>
|
||||
""";
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,41 @@ knoe:
|
||||
adminPrincipals: []
|
||||
kerberos:
|
||||
# REQUIRED for SPNEGO when enabled. Provide via env.
|
||||
servicePrincipal: ""
|
||||
keytabPath: ""
|
||||
realm: ""
|
||||
servicePrincipal: ${KNOE_KERBEROS_SERVICE_PRINCIPAL:}
|
||||
keytabPath: ${KNOE_KERBEROS_KEYTAB_PATH:}
|
||||
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
|
||||
|
||||
@ -1,23 +1,15 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"assignments": {},
|
||||
"cluster_name": "knoe-db",
|
||||
"desired_instances": 3,
|
||||
"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"
|
||||
],
|
||||
"eligible_nodes": [],
|
||||
"metadata": {
|
||||
"prior_plan_present": true,
|
||||
"reason": "reused",
|
||||
"regenerated": false,
|
||||
"reused": true
|
||||
"reason": "new_cluster",
|
||||
"regenerated": true,
|
||||
"reused": false
|
||||
},
|
||||
"plan_hash": "a44825aeb5ed4e13",
|
||||
"plan_id": "cnpg-placement-a44825aeb5ed4e13",
|
||||
"plan_hash": "965d2fde4035059f",
|
||||
"plan_id": "cnpg-placement-965d2fde4035059f",
|
||||
"schema_version": "v1"
|
||||
}
|
||||
|
||||
107
conf/k3d.cfg
107
conf/k3d.cfg
@ -1,5 +1,5 @@
|
||||
; 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.
|
||||
|
||||
[User]
|
||||
@ -28,37 +28,35 @@ database_options.ext.pgvector = true
|
||||
database_options.ext.postgis = true
|
||||
database_options.ext.postgres_fdw = true
|
||||
database_options.version_type = v18
|
||||
dependencies.ansible.install = true
|
||||
dependencies.auto_install_missing = true
|
||||
dependencies.brew.install = true
|
||||
dependencies.containerd.install = true
|
||||
dependencies.docker-buildx.install = true
|
||||
dependencies.docker.install = true
|
||||
dependencies.gcloud.install = true
|
||||
dependencies.k3d.install = true
|
||||
dependencies.kubectl.install = true
|
||||
dependencies.kubectx.install = true
|
||||
dependencies.op.install = true
|
||||
dependencies.opentofu.install = true
|
||||
dependencies.python.install = true
|
||||
dependencies.verify_all = false
|
||||
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 =
|
||||
env_setup.CLUSTER_NAME = ${CLUSTER_NAME}
|
||||
env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE}
|
||||
env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe/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
|
||||
env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe-db/conf
|
||||
gitops.git_provider = Gitea
|
||||
gitops.node_selector =
|
||||
init_cluster.argocd_enabled = false
|
||||
init_cluster.at_rest_encryption_enabled = true
|
||||
init_cluster.cluster_env = dev
|
||||
init_cluster.deployment_target = knoe-dev-cluster
|
||||
init_cluster.gitops_enabled = true
|
||||
init_cluster.k3s_server_url = https://myrddin.knoe.org:6443
|
||||
init_cluster.gitops_enabled = false
|
||||
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.kerberos_enabled = true
|
||||
init_cluster.kerberos_enabled = false
|
||||
init_cluster.mode = k3d
|
||||
init_cluster.start_cluster = true
|
||||
init_cluster.supabase_enabled = false
|
||||
@ -73,10 +71,10 @@ init_password.db_password_confirm =
|
||||
init_password.db_username = root
|
||||
init_password.generate_ssh_key = true
|
||||
init_scripts.run_scripts = true
|
||||
kerberos_config.enabled = true
|
||||
kerberos_config.kdc = 10.0.0.3
|
||||
kerberos_config.enabled = false
|
||||
kerberos_config.kdc =
|
||||
kerberos_config.password =
|
||||
kerberos_config.realm = PROLE.ORG
|
||||
kerberos_config.realm =
|
||||
kerberos_config.test_connection = false
|
||||
kerberos_config.user = administrator
|
||||
network_scan.run = true
|
||||
@ -84,132 +82,87 @@ ollama_config.model =
|
||||
ollama_config.server_host =
|
||||
ollama_config.server_port = 11434
|
||||
supabase_config.pv_base_dir = /synology/d005
|
||||
supabase_config.pv_node = gandalf.knoe.org
|
||||
supabase_config.pv_node =
|
||||
|
||||
[Global]
|
||||
; 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_NAME = knoe-db
|
||||
CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json
|
||||
CNPG_PLACEMENT_PLAN_HASH = 965d2fde4035059f
|
||||
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-965d2fde4035059f
|
||||
DATABASE_NAMESPACE = knoe-db-18-008-18-009-18-013-18-014
|
||||
DATABASE_NAMESPACE = knoe-db-18-140
|
||||
DB_HOST_PORT = 5432
|
||||
DEPLOYMENT_MODE = k3d
|
||||
DEPLOYMENT_TARGET = knoe-dev-cluster
|
||||
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
|
||||
KUBECONTEXT = dev
|
||||
MONITORING_STORAGE_CLASS = local-path
|
||||
OPENTOFU_URL = http://127.0.0.1:8080
|
||||
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_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
|
||||
SUPABASE_PV_BASE = /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]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Dependencies]
|
||||
STATUS = All installed
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Network]
|
||||
AD_DC_HOST = myrddin.knoe.org
|
||||
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
|
||||
KERBEROS_AUTO_ENABLED = False
|
||||
|
||||
[Port Forwards]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[System Environment]
|
||||
KNOE_CONF = $HOME/dev/knoe/conf
|
||||
PROLE_DATA = $HOME/dev/knoe/data
|
||||
KNOE_HOME = $HOME/dev/knoe
|
||||
PROLE_LOGS = $HOME/dev/knoe/logs
|
||||
KNOE_SERVICE = $HOME/dev/knoe/etc
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Monitoring]
|
||||
MONITORING_STORAGE_CLASS = local-path
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Kerberos Authentication]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[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]
|
||||
AT_REST_ENCRYPTION_ENABLED = true
|
||||
GITOPS_ENABLED = True
|
||||
GITOPS_PROVIDER = GitLab
|
||||
KERBEROS_ENABLED = true
|
||||
SUPABASE_ENABLED = false
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[GitOps]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Database Creation]
|
||||
APP_CLUSTER_NAME = knoe-dev-0
|
||||
DB_CLUSTER_NAME = knoe-cnpg-0
|
||||
DB_USER = root
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Initialize Cluster]
|
||||
ENVIRONMENT = dev
|
||||
K3S_SERVER_URL = https://myrddin.knoe.org:6443
|
||||
K3S_TOKEN = ${KNOE_SECRET:v1:ozzcomisjsQYIkSH:Ytp91WR_iP4tJyTAmdH_SRhcKycgzea0zLAgTBNxDsQaBPM-pR_VK3u9wc5QkFzszdAHZGBhVN2HKyqnz-cqDR0WAus88DFbF4zWlgvl6gKEAynaXbdMwAa6vYLUGi8ZE0u1pRiO4KJyiulhBIpfoMReM1Wu6Mj1-20hXw==}
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Dev Cluster (k3d)]
|
||||
CLUSTER_ENV = dev
|
||||
DISPLAY_NAME = knoe-dev-cluster
|
||||
KUBECTL_CONTEXT = dev
|
||||
MODE = k3d
|
||||
|
||||
[Service Cluster (k3s)]
|
||||
CLUSTER_ENV = 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==}
|
||||
MODE = k3s
|
||||
PIPELINE_URL = http://127.0.0.1:8080
|
||||
|
||||
[GCP]
|
||||
BILLING_ACCOUNT = 01193C-25783B-3211AD
|
||||
BILLING_PROJECT = plenary-truck-485623-p7
|
||||
ORG_ID = 584001916389
|
||||
PROJECT_ID = plenary-truck-485623-p7
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Prod Cluster (k8s)]
|
||||
ARTIFACTS_DIR = $HOME/dev/knoe/data/staging
|
||||
ARTIFACTS_DIR = $HOME/dev/knoe-db/data/staging
|
||||
CLUSTER_ENV = knoe-prod-cluster
|
||||
DISPLAY_NAME = knoe-prod-cluster
|
||||
MODE = k8s
|
||||
PIPELINE_URL = http://127.0.0.1:8080
|
||||
|
||||
[Docker Build]
|
||||
LOCAL_REGISTRY = localhost:5000
|
||||
LOCAL_REGISTRY_INTERNAL = k3d-knoe-registry.localhost:5000
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Initialization Scripts]
|
||||
; No configuration values captured yet for this section.
|
||||
@ -219,4 +172,4 @@ MODE = k3d
|
||||
TARGET = knoe-dev-cluster
|
||||
|
||||
[Install]
|
||||
STATUS = Failed
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
44
conf/k3s.cfg
44
conf/k3s.cfg
@ -76,7 +76,7 @@ init_cluster.db_cluster_region =
|
||||
init_cluster.db_cluster_zones =
|
||||
init_cluster.deployment_target = knoe-service-cluster
|
||||
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.kerberos_enabled = true
|
||||
init_cluster.mode = k3s
|
||||
@ -87,7 +87,7 @@ init_cluster.supabase_enabled = false
|
||||
init_cluster.supabase_meta_enabled = true
|
||||
init_cluster.supabase_realtime_enabled = true
|
||||
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.run_deploy = true
|
||||
init_db_build.run_build = true
|
||||
@ -113,24 +113,24 @@ ollama_config.model =
|
||||
ollama_config.server_host =
|
||||
ollama_config.server_port = 11434
|
||||
supabase_config.pv_base_dir = /synology/d005
|
||||
supabase_config.pv_node = gandalf.knoe.org
|
||||
supabase_config.pv_node = gandalf.prole.org
|
||||
|
||||
[Global]
|
||||
; 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_HOSTNAME = api.prole.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 = service
|
||||
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_HASH = 962fb2e7bfd2a48b
|
||||
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-962fb2e7bfd2a48b
|
||||
CNPG_STAGE1_NODE = gandalf.knoe.org
|
||||
CNPG_STAGE1_NODE = gandalf.prole.org
|
||||
DATABASE_NAMESPACE = knoe-db
|
||||
DB_HOST_PORT = 5432
|
||||
DB_PASSWORD = ${KNOE_SECRET:v1:Vc5Sow_MQksbOtOJ:bvD1ABxenFlo0304dhf0Me_nzBX0SvLJ7oFvE_TkHGqc0YF8}
|
||||
@ -138,30 +138,30 @@ DEPLOYMENT_MODE = k3s
|
||||
DEPLOYMENT_TARGET = knoe-service-cluster
|
||||
DOCKER_IMPORT_DIR =
|
||||
DOCKER_PRELOAD = false
|
||||
GITEA_HOSTNAME = git-internal.knoe.org
|
||||
GITLAB_PUBLIC_HOSTS = git.knoe.org
|
||||
GITEA_HOSTNAME = git-internal.prole.org
|
||||
GITLAB_PUBLIC_HOSTS = git.prole.org
|
||||
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==}
|
||||
KNOE_DB_USER = root
|
||||
KNOE_IMAGE_REGISTRY = registry.knoe.org
|
||||
KNOE_IMAGE_REGISTRY = registry.prole.org
|
||||
MONITORING_STORAGE_CLASS = local-path
|
||||
OPENTOFU_URL = http://127.0.0.1:8080
|
||||
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
|
||||
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==}
|
||||
PROTECTED_DB_HOSTS = db.knoe.org
|
||||
PROTECTED_GIT_HOSTS = git.knoe.org
|
||||
PROTECTED_DB_HOSTS = db.prole.org
|
||||
PROTECTED_GIT_HOSTS = git.prole.org
|
||||
REDIS_HOST = redis-master.knoe-system.svc.cluster.local
|
||||
REGISTRY_NAMESPACE = knoe-system
|
||||
SERVICE_HOSTNAME = svc.knoe.org
|
||||
SERVICE_HOSTNAME = svc.prole.org
|
||||
SERVICE_NAMESPACE = knoe-system
|
||||
SUPABASE_API_HOSTNAME = supabase.knoe.org
|
||||
SUPABASE_API_HOSTNAME = supabase.prole.org
|
||||
SUPABASE_PV_BASE = /synology/d005
|
||||
SUPABASE_PV_BASE_DIR = /synology/d005
|
||||
SUPABASE_PV_NODE = gandalf.knoe.org
|
||||
SUPABASE_STUDIO_HOSTNAME = db.knoe.org
|
||||
SUPABASE_PV_NODE = gandalf.prole.org
|
||||
SUPABASE_STUDIO_HOSTNAME = db.prole.org
|
||||
SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d004,/synology/d005
|
||||
|
||||
[Welcome]
|
||||
@ -171,13 +171,13 @@ SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d004,/synology/d005
|
||||
STATUS = All installed
|
||||
|
||||
[Network]
|
||||
AD_DC_HOST = myrddin.knoe.org
|
||||
AD_DC_HOST = myrddin.prole.org
|
||||
AD_DC_IP = 10.0.0.3
|
||||
ANSIBLE_DOMAIN = knoe.org
|
||||
ANSIBLE_DOMAIN = prole.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"]}
|
||||
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_AUTO_DETECTED = 10.0.0.3
|
||||
KERBEROS_AUTO_ENABLED = True
|
||||
@ -226,7 +226,7 @@ DB_USER = root
|
||||
|
||||
[Initialize Cluster]
|
||||
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==}
|
||||
|
||||
[Dev Cluster (k3d)]
|
||||
@ -238,7 +238,7 @@ MODE = k3d
|
||||
[Service Cluster (k3s)]
|
||||
CLUSTER_ENV = 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==}
|
||||
KUBECTL_CONTEXT = knoe-service-cluster
|
||||
MODE = k3s
|
||||
|
||||
@ -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_domain = _cfg_first(g, e, i, "GITLAB_DOMAIN", "GITLAB_HOSTNAME", "gitlab_domain", "gitlab_hostname")
|
||||
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()]
|
||||
if not gitlab_public_hosts:
|
||||
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")
|
||||
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")
|
||||
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"
|
||||
|
||||
|
||||
307
deploy/gcp/gke/knoe-auth-deployment.yaml
Normal file
307
deploy/gcp/gke/knoe-auth-deployment.yaml
Normal 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
|
||||
108
deploy/gcp/gke/knoe-kdc-configmap.yaml
Normal file
108
deploy/gcp/gke/knoe-kdc-configmap.yaml
Normal 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
|
||||
28
deploy/gcp/gke/knoe-kdc-secrets.yaml
Normal file
28
deploy/gcp/gke/knoe-kdc-secrets.yaml
Normal 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"
|
||||
@ -16,7 +16,7 @@ spec:
|
||||
app: garage
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: myrddin.knoe.org
|
||||
kubernetes.io/hostname: myrddin.prole.org
|
||||
containers:
|
||||
- name: garage
|
||||
image: dxflrs/garage:v1.3.1
|
||||
|
||||
@ -16,7 +16,7 @@ spec:
|
||||
spec:
|
||||
initContainers:
|
||||
- name: keytab-bootstrap
|
||||
image: myrddin.knoe.org:5000/knoe-authority:latest
|
||||
image: myrddin.prole.org:5000/knoe-authority:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/bash
|
||||
@ -202,7 +202,7 @@ spec:
|
||||
subPath: krb5.conf
|
||||
readOnly: true
|
||||
- name: kdc
|
||||
image: myrddin.knoe.org:5000/knoe-authority:latest
|
||||
image: myrddin.prole.org:5000/knoe-authority:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/bin/bash", "/opt/knoe-kdc/entrypoint.sh"]
|
||||
env:
|
||||
|
||||
@ -15,8 +15,8 @@ data:
|
||||
admin_server = 127.0.0.1
|
||||
}
|
||||
PROLE.ORG = {
|
||||
kdc = myrddin.knoe.org
|
||||
admin_server = myrddin.knoe.org
|
||||
kdc = myrddin.prole.org
|
||||
admin_server = myrddin.prole.org
|
||||
}
|
||||
|
||||
[capaths]
|
||||
|
||||
@ -56,7 +56,7 @@ spec:
|
||||
app: opentofu
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: myrddin.knoe.org
|
||||
kubernetes.io/hostname: myrddin.prole.org
|
||||
containers:
|
||||
- name: opentofu-ui
|
||||
image: nginx:1.27-alpine
|
||||
|
||||
775
docs/pipeline-phases.md
Normal file
775
docs/pipeline-phases.md
Normal 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: 8–15 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 1–2 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.
|
||||
@ -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
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| [`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. |
|
||||
| File | Initiative | When you should read it |
|
||||
| --- | --- | --- |
|
||||
| [`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.
|
||||
- [`deployment-modes.md`](../../../knoe-db/docs/plans/deployment-modes.md) — Four-mode installer + welcome-screen selector. Architectural reference.
|
||||
A plan in this directory has one of three statuses, declared at the top:
|
||||
|
||||
(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.
|
||||
|
||||
188
docs/plans/deployment-modes.md
Normal file
188
docs/plans/deployment-modes.md
Normal 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.
|
||||
483
docs/plans/knoe-auth-round-1.md
Normal file
483
docs/plans/knoe-auth-round-1.md
Normal 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 482–510. 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.
|
||||
@ -4,7 +4,7 @@ metadata:
|
||||
name: knoe-hostprobe-myrddin
|
||||
namespace: kube-system
|
||||
spec:
|
||||
nodeName: myrddin.knoe.org
|
||||
nodeName: myrddin.prole.org
|
||||
hostNetwork: true
|
||||
hostPID: true
|
||||
tolerations:
|
||||
|
||||
@ -4,7 +4,7 @@ metadata:
|
||||
name: knoe-hostprobe-pi
|
||||
namespace: kube-system
|
||||
spec:
|
||||
nodeName: pi.knoe.org
|
||||
nodeName: pi.prole.org
|
||||
hostNetwork: true
|
||||
hostPID: true
|
||||
tolerations:
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
myrddin.knoe.org 10.0.0.3
|
||||
raspberry.knoe.org 10.0.0.4
|
||||
pi.knoe.org 10.0.0.5
|
||||
synology.knoe.org 10.0.0.203
|
||||
morgoth.knoe.org 10.0.0.204
|
||||
zinfandel.knoe.org 10.0.0.205
|
||||
aventage.knoe.org 10.0.0.206
|
||||
retropie.knoe.org 10.0.0.207
|
||||
fairyland.knoe.org 10.0.0.208
|
||||
k8s.knoe.org zinfandel.knoe.org
|
||||
mc.knoe.org 73.15.20.166
|
||||
morana.knoe.org 10.0.0.66
|
||||
ollama.knoe.org 73.15.20.166
|
||||
svc.knoe.org 73.15.20.166
|
||||
www.knoe.org ghs.googlehosted.com
|
||||
myrddin.prole.org 10.0.0.3
|
||||
raspberry.prole.org 10.0.0.4
|
||||
pi.prole.org 10.0.0.5
|
||||
synology.prole.org 10.0.0.203
|
||||
morgoth.prole.org 10.0.0.204
|
||||
zinfandel.prole.org 10.0.0.205
|
||||
aventage.prole.org 10.0.0.206
|
||||
retropie.prole.org 10.0.0.207
|
||||
fairyland.prole.org 10.0.0.208
|
||||
k8s.prole.org zinfandel.prole.org
|
||||
mc.prole.org 73.15.20.166
|
||||
morana.prole.org 10.0.0.66
|
||||
ollama.prole.org 73.15.20.166
|
||||
svc.prole.org 73.15.20.166
|
||||
www.prole.org ghs.googlehosted.com
|
||||
@ -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_BASE_DIR="${GITEA_PV_BASE_DIR:-/synology/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}"
|
||||
KNOE_DB_NAMESPACE="${KNOE_DB_NAMESPACE:-${DATABASE_NAMESPACE:-knoe-db}}"
|
||||
KNOE_DB_CLUSTER="${KNOE_DB_CLUSTER:-${CLUSTER_NAME:-knoe-db}}"
|
||||
|
||||
@ -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 "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" \
|
||||
"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
|
||||
if [[ "$desired_cr" == *"gitlab-gitaly-static"* ]]; then
|
||||
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)
|
||||
if [[ -z "$live_sts_yaml" ]]; then
|
||||
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)."
|
||||
local _live_sc
|
||||
_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
|
||||
local live_sts_yaml
|
||||
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..."
|
||||
# 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
|
||||
@ -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"
|
||||
fi
|
||||
|
||||
if [[ "$gitaly_sts_yaml" == *"gandalf.knoe.org"* ]]; then
|
||||
if [[ "$gitaly_sts_yaml" == *"gandalf.prole.org"* ]]; then
|
||||
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
|
||||
if [[ "$gitaly_sts_yaml" == *"gitlab-gitaly-static"* ]]; then
|
||||
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}"
|
||||
|
||||
# GitLab public domain (configurable; default follows deployment mode)
|
||||
_default_gitlab_domain="git.knoe.org"
|
||||
_default_gitlab_domain="git.prole.org"
|
||||
if [[ "$MODE" == "k8s" ]]; then
|
||||
_default_gitlab_domain="git.knoe.dev"
|
||||
fi
|
||||
@ -2418,7 +2418,7 @@ PRIMARY_GITLAB_HOST="${GITLAB_PUBLIC_HOSTS[0]}"
|
||||
GITLAB_DOMAIN="$PRIMARY_GITLAB_HOST"
|
||||
PRIMARY_GITLAB_DOMAIN_ROOT="${PRIMARY_GITLAB_HOST#*.}"
|
||||
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
|
||||
|
||||
# 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
|
||||
# OAuth2 client credentials (client_id, client_secret, redirect_uri).
|
||||
# To disable OIDC: unset FRONTDOOR_HOST before running.
|
||||
_default_auth_hostname="api.knoe.org"
|
||||
_default_auth_hostname="api.prole.org"
|
||||
if [[ "$MODE" == "k8s" ]]; then
|
||||
_default_auth_hostname="api.knoe.dev"
|
||||
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() {
|
||||
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 \
|
||||
>/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}}"
|
||||
for res_type in kongplugins kongingresses; do
|
||||
kubectl -n "$gitea_ns" delete "$res_type" --all >/dev/null 2>&1 || true
|
||||
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 \
|
||||
-l "app.kubernetes.io/name=gitea" >/dev/null 2>&1 || true
|
||||
kubectl -n "$gitea_ns" delete ingress \
|
||||
@ -2945,7 +2945,7 @@ fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
warn "Gitea release detected — removing to free git.knoe.org for GitLab..."
|
||||
@ -3070,7 +3070,7 @@ fi
|
||||
if [[ "$MODE" == "k8s" ]]; then
|
||||
# In k8s/GKE mode, we do NOT use static storage nodes or nodeSelectors.
|
||||
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."
|
||||
NODE_SELECTOR=""
|
||||
fi
|
||||
@ -3719,7 +3719,7 @@ setup_gitlab_storage() {
|
||||
local gitaly_sts_yaml
|
||||
gitaly_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true)
|
||||
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
|
||||
fi
|
||||
fi
|
||||
@ -3743,7 +3743,7 @@ setup_gitlab_storage() {
|
||||
# Give it a moment to process deletions
|
||||
sleep 2
|
||||
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:
|
||||
kubectl -n $NAMESPACE scale sts ${GITLAB_RELEASE}-gitaly --replicas=0
|
||||
kubectl -n $NAMESPACE delete pvc repo-data-gitlab-gitaly-0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,14 +7,14 @@ set -euo pipefail
|
||||
# - Provision Kerberos principals and database accounts for knoe-system users
|
||||
# - Creates: admin@PROLE.LOCAL (master password), guest@PROLE.LOCAL (read-only),
|
||||
# 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
|
||||
# - Sets up service admin access: ArgoCD RBAC, Gitea, GitLab
|
||||
#
|
||||
# Prerequisites:
|
||||
# init_kdc.sh initialize (with PROLE_KDC_REALM=PROLE.LOCAL,
|
||||
# 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_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_ADMIN_PRINCIPAL="${KNOE_ADMIN_PRINCIPAL:-admin}"
|
||||
PROLE_KDC_REALM="${PROLE_KDC_REALM:-PROLE.LOCAL}"
|
||||
GITEA_HOST="${GITEA_HOST:-git.knoe.org}"
|
||||
GITLAB_HOST="${GITLAB_HOST:-gitlab.knoe.org}"
|
||||
GITEA_HOST="${GITEA_HOST:-git.prole.org}"
|
||||
GITLAB_HOST="${GITLAB_HOST:-gitlab.prole.org}"
|
||||
GITEA_NAMESPACE="${GITEA_NAMESPACE:-gitea}"
|
||||
GITLAB_NAMESPACE="${GITLAB_NAMESPACE:-gitlab}"
|
||||
ARGOCD_NAMESPACE="${ARGOCD_NAMESPACE:-argocd}"
|
||||
@ -455,8 +455,8 @@ SQL
|
||||
# 8. Create knoe.user schema tables and bootstrap users
|
||||
if [[ -n "$primary" ]]; then
|
||||
create_knoe_schema "$primary"
|
||||
provision_user "$primary" "chrisfu" "chrisfu@knoe.org" "Chris Fu" "admin"
|
||||
provision_user "$primary" "ron" "ron@knoe.org" "Ron" "developer"
|
||||
provision_user "$primary" "chrisfu" "chrisfu@prole.org" "Chris Fu" "admin"
|
||||
provision_user "$primary" "ron" "ron@prole.org" "Ron" "developer"
|
||||
else
|
||||
warn "CNPG primary not found — skipping knoe.user schema and user provisioning"
|
||||
fi
|
||||
@ -473,7 +473,7 @@ SQL
|
||||
log "=== Initialization complete ==="
|
||||
log ""
|
||||
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 " 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"
|
||||
|
||||
@ -137,9 +137,9 @@ if [[ "${KNOE_MODE:-}" == "k8s" ]]; then
|
||||
else
|
||||
KONG_NAME="${KONG_NAME:-knoe-svc-kong}"
|
||||
KONG_CONFIG_NAME="${KONG_CONFIG_NAME:-knoe-svc-kong-config}"
|
||||
SERVICE_HOSTNAME="${SERVICE_HOSTNAME:-svc.knoe.org}"
|
||||
AUTH_HOSTNAME="${AUTH_HOSTNAME:-api.knoe.org}"
|
||||
GITEA_HOSTNAME="${GITEA_HOSTNAME:-${GITEA_DOMAIN:-git.knoe.org}}"
|
||||
SERVICE_HOSTNAME="${SERVICE_HOSTNAME:-svc.prole.org}"
|
||||
AUTH_HOSTNAME="${AUTH_HOSTNAME:-api.prole.org}"
|
||||
GITEA_HOSTNAME="${GITEA_HOSTNAME:-${GITEA_DOMAIN:-git.prole.org}}"
|
||||
SERVICE_INGRESS_TLS_ENABLED="${SERVICE_INGRESS_TLS_ENABLED:-1}"
|
||||
fi
|
||||
KONG_PROXY_PORT="${KONG_PROXY_PORT:-8000}"
|
||||
|
||||
@ -198,7 +198,7 @@ validate_monitoring_storage_classes() {
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -207,11 +207,11 @@ resolve_monitoring_primary_node() {
|
||||
printf '%s' "${MONITORING_PRIMARY_NODE}"
|
||||
return 0
|
||||
fi
|
||||
if kubectl get node merlin.knoe.org >/dev/null 2>&1; then
|
||||
printf '%s' "merlin.knoe.org"
|
||||
if kubectl get node merlin.prole.org >/dev/null 2>&1; then
|
||||
printf '%s' "merlin.prole.org"
|
||||
return 0
|
||||
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
|
||||
printf '%s' "$node"
|
||||
return 0
|
||||
@ -461,7 +461,7 @@ render_node_selector() {
|
||||
local indent="$1"
|
||||
cat <<EOF
|
||||
${indent}nodeSelector:
|
||||
${indent} knoe.org/node-role: general
|
||||
${indent} prole.org/node-role: general
|
||||
EOF
|
||||
}
|
||||
|
||||
@ -492,7 +492,7 @@ render_tolerations() {
|
||||
mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-}}"
|
||||
|
||||
local key value effect
|
||||
key="${MONITORING_TAINT_KEY:-knoe.org/monitoring}"
|
||||
key="${MONITORING_TAINT_KEY:-prole.org/monitoring}"
|
||||
value="${MONITORING_TAINT_VALUE:-true}"
|
||||
effect="${MONITORING_TAINT_EFFECT:-NoSchedule}"
|
||||
|
||||
@ -515,7 +515,7 @@ ${indent} nodeAffinity:
|
||||
${indent} requiredDuringSchedulingIgnoredDuringExecution:
|
||||
${indent} nodeSelectorTerms:
|
||||
${indent} - matchExpressions:
|
||||
${indent} - key: knoe.org/node-role
|
||||
${indent} - key: prole.org/node-role
|
||||
${indent} operator: In
|
||||
${indent} values:
|
||||
${indent} - general
|
||||
@ -1293,7 +1293,7 @@ $(render_tolerations " ")
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values:
|
||||
- pi.knoe.org
|
||||
- pi.prole.org
|
||||
EOF
|
||||
|
||||
local monitoring_helm_server_side
|
||||
|
||||
@ -189,7 +189,7 @@ resolve_registry_node_selector() {
|
||||
return 0
|
||||
fi
|
||||
# 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
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ set -euo pipefail
|
||||
: "${K3D_REGISTRY_CONFIG:=}" # optional path to registries.yaml
|
||||
|
||||
# Remote server details
|
||||
: "${K3S_REMOTE_SERVER_HOST:=raspberry.knoe.org}"
|
||||
: "${K3S_REMOTE_SERVER_HOST:=raspberry.prole.org}"
|
||||
: "${K3S_REMOTE_SERVER_PORT:=6443}"
|
||||
: "${K3S_REMOTE_TOKEN:=}" # required
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# 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_SSH_KEY:=$HOME/.ssh/id_rsa}"
|
||||
: "${K3S_VERSION:=}"
|
||||
|
||||
@ -10,8 +10,8 @@ fi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERVER=${K3S_SERVER:-"https://myrddin.knoe.org:6443"}
|
||||
REMOTE_HOST=${K3S_REMOTE_HOST:-"myrddin.knoe.org"}
|
||||
SERVER=${K3S_SERVER:-"https://myrddin.prole.org:6443"}
|
||||
REMOTE_HOST=${K3S_REMOTE_HOST:-"myrddin.prole.org"}
|
||||
REMOTE_USER=${K3S_REMOTE_USER:-"ansible"}
|
||||
SSH_KEY=${K3S_SSH_KEY:-"$HOME/.ssh/id_ed25519_ansible"}
|
||||
REMOTE_KUBECONFIG=${K3S_REMOTE_KUBECONFIG:-"/etc/rancher/k3s/k3s.yaml"}
|
||||
|
||||
@ -7,7 +7,7 @@ data:
|
||||
kube_config.sh: |
|
||||
#!/bin/bash
|
||||
# k3s/kube_config.sh - Automatically configure knoe-k3s context
|
||||
SERVER="https://myrddin.knoe.org:6443"
|
||||
SERVER="https://myrddin.prole.org:6443"
|
||||
CLUSTER="knoe-k3s"
|
||||
USER="knoe-k3s"
|
||||
CONTEXT="knoe-k3s"
|
||||
@ -109,7 +109,7 @@ metadata:
|
||||
kubernetes.io/ingress.class: traefik
|
||||
spec:
|
||||
rules:
|
||||
- host: myrddin.knoe.org
|
||||
- host: myrddin.prole.org
|
||||
http:
|
||||
paths:
|
||||
- path: /k3s/kube_config.sh
|
||||
|
||||
@ -17,5 +17,5 @@ data:
|
||||
}
|
||||
|
||||
[domain_realm]
|
||||
.knoe.org = PROLE.ORG
|
||||
knoe.org = PROLE.ORG
|
||||
.prole.org = PROLE.ORG
|
||||
prole.org = PROLE.ORG
|
||||
|
||||
@ -7,4 +7,9 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/deploy/opentofu/k3s/.terraform" />
|
||||
</content>
|
||||
</component>
|
||||
<component name="FacetManager">
|
||||
<facet type="Python" name="Python">
|
||||
<configuration sdkName="Python 3.14 (knoe-db)" />
|
||||
</facet>
|
||||
</component>
|
||||
</module>
|
||||
@ -814,7 +814,7 @@ def _detect_ansible_storage_mounts(inventory_path: Path) -> dict[str, dict[str,
|
||||
|
||||
Returns a mapping like:
|
||||
{
|
||||
"d001": {"host": "myrddin.knoe.org", "path": "/synology/d001"},
|
||||
"d001": {"host": "myrddin.prole.org", "path": "/synology/d001"},
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
@ -836,7 +836,7 @@ class DockerBuildMilestone(Milestone):
|
||||
registry = (state.config_data.get("Global", {}) or {}).get("KNOE_IMAGE_REGISTRY", "").strip()
|
||||
|
||||
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:
|
||||
server = server.split("://")[1]
|
||||
if ":" in server:
|
||||
|
||||
@ -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_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_list = [n.strip() for n in excluded_nodes.split(",") if n.strip()]
|
||||
|
||||
|
||||
@ -390,6 +390,8 @@ class KnoeInstaller(
|
||||
)
|
||||
self.project_root = PROJECT_ROOT
|
||||
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_database_options_state()
|
||||
self.screens = None
|
||||
@ -525,7 +527,7 @@ class KnoeInstaller(
|
||||
]
|
||||
self.nav_widgets = {}
|
||||
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()
|
||||
|
||||
# Validation attributes
|
||||
|
||||
@ -262,44 +262,23 @@ class NavigationMixin:
|
||||
except Exception:
|
||||
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:
|
||||
"""Switch the active deployment mode and persist it to the environment."""
|
||||
self.deployment_mode.set(mode)
|
||||
os.environ["KNOE_MODE"] = mode
|
||||
self._refresh_mode_tabs()
|
||||
|
||||
def _refresh_mode_tabs(self) -> None:
|
||||
"""Update tab highlight colours to reflect the active deployment mode."""
|
||||
active = self.deployment_mode.get()
|
||||
for value, widget in self._mode_tab_widgets.items():
|
||||
if value == active:
|
||||
widget.configure(bg=self._MODE_COLORS.get(value, "#888"), fg="white")
|
||||
else:
|
||||
widget.configure(bg="#D5D5C5", fg="#555")
|
||||
"""No-op: mode tabs replaced by welcome screen card selector."""
|
||||
pass
|
||||
|
||||
def _is_min_mode(self) -> bool:
|
||||
"""Return True when the user has selected minimal (containerd-only) mode."""
|
||||
return self.deployment_mode.get() == "min"
|
||||
|
||||
def _create_sidebar_nav(self):
|
||||
"""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 ──────────────────────────────────────────────
|
||||
tk.Label(
|
||||
self.sidebar,
|
||||
@ -375,6 +354,9 @@ class NavigationMixin:
|
||||
self.show_page(seq[-1] if seq else "deps_summary")
|
||||
return
|
||||
if current_id == "env_setup":
|
||||
if self._is_min_mode():
|
||||
self.show_page("deps_summary")
|
||||
else:
|
||||
self.show_page("network_scan")
|
||||
return
|
||||
if current_id == "init_cluster":
|
||||
@ -397,6 +379,9 @@ class NavigationMixin:
|
||||
self.show_page("common_services")
|
||||
return
|
||||
if current_id == "init_password":
|
||||
if self._is_min_mode():
|
||||
self.show_page("env_setup")
|
||||
else:
|
||||
self.show_page("init_db_build")
|
||||
return
|
||||
if current_id == "init_db_build":
|
||||
@ -426,6 +411,10 @@ class NavigationMixin:
|
||||
if current_id == "init_cnpg_deploy":
|
||||
self.show_page("supabase_config")
|
||||
return
|
||||
if current_id == "security":
|
||||
if self._is_min_mode():
|
||||
self.show_page("init_scripts")
|
||||
return
|
||||
if current_id == "create_installer":
|
||||
self.show_page("init_cnpg_deploy")
|
||||
return
|
||||
@ -492,6 +481,9 @@ class NavigationMixin:
|
||||
return
|
||||
|
||||
if current_id == "network_scan":
|
||||
if self._is_min_mode():
|
||||
self.show_page("env_setup")
|
||||
return
|
||||
# Capture network scan info
|
||||
try:
|
||||
self.knoe_cfg_data["Network"][
|
||||
@ -593,6 +585,9 @@ class NavigationMixin:
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if self._is_min_mode():
|
||||
self.show_page("init_password")
|
||||
else:
|
||||
self.show_page("init_cluster")
|
||||
return
|
||||
|
||||
@ -678,7 +673,9 @@ class NavigationMixin:
|
||||
"Completed" if getattr(self, "_scripts_success", False) else "Attempted"
|
||||
)
|
||||
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")
|
||||
else:
|
||||
self.show_page("argocd_config")
|
||||
@ -885,7 +882,12 @@ class NavigationMixin:
|
||||
|
||||
# Page-specific adjustments
|
||||
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
|
||||
if getattr(self, "_built_success", False):
|
||||
self.next_button.configure(text="Next")
|
||||
|
||||
@ -1,68 +1,198 @@
|
||||
"""Welcome / splash screen."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
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:
|
||||
"""Welcome / splash screen."""
|
||||
|
||||
def _render_welcome_page(self):
|
||||
# Letterhead at top right
|
||||
content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx
|
||||
content_width = self.bg_canvas.winfo_width() or 975
|
||||
right_margin = content_width - 48
|
||||
|
||||
ui.canvas_text(
|
||||
self,
|
||||
right_margin,
|
||||
40,
|
||||
"knoe.dev",
|
||||
fill="#6e6e73",
|
||||
font=("SF Pro Text", 32, "bold"),
|
||||
anchor="ne",
|
||||
# Letterhead
|
||||
ui.canvas_text(self, right_margin, 40, "knoe.dev",
|
||||
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")
|
||||
|
||||
self._render_title("Welcome to Knoe.DB", y=145)
|
||||
|
||||
intro = (
|
||||
"Knoe.DB Installer sets up a production-grade PostgreSQL cluster with Kerberos "
|
||||
"authentication.\nPick the mode that matches your hardware — we'll walk you through every step."
|
||||
)
|
||||
ui.canvas_text(
|
||||
self,
|
||||
right_margin,
|
||||
85,
|
||||
"infrastructure.auto()",
|
||||
fill="#6e6e73",
|
||||
font=("SF Pro Text", 18),
|
||||
anchor="ne",
|
||||
ui.canvas_text(self, 48, 200, intro,
|
||||
fill="#1d1d1f", font=("SF Pro Text", 13), width=800)
|
||||
|
||||
# Mode selector cards
|
||||
card_w = 185
|
||||
card_h = 200
|
||||
card_gap = 18
|
||||
card_x0 = 48
|
||||
card_y0 = 265
|
||||
|
||||
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"
|
||||
),
|
||||
)
|
||||
|
||||
# Welcome title
|
||||
self._render_title("Welcome", y=150)
|
||||
|
||||
welcome_text = (
|
||||
"This installer will guide you through the process of setting up the Knoe Database and its supporting "
|
||||
"infrastructure. We have designed this process to be as automated as possible, ensuring that your "
|
||||
"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."
|
||||
# "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)
|
||||
|
||||
ui.canvas_text(
|
||||
self,
|
||||
48,
|
||||
220,
|
||||
welcome_text,
|
||||
fill="black",
|
||||
font=("SF Pro Text", 13),
|
||||
width=750,
|
||||
# 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()
|
||||
|
||||
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):
|
||||
self.splash_scan_running = True
|
||||
self.splash_scan_done_at = None
|
||||
|
||||
4
pom.xml
4
pom.xml
@ -20,7 +20,9 @@
|
||||
<java.version>24</java.version>
|
||||
<spring-modulith.version>1.0.0</spring-modulith.version>
|
||||
<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>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
36
pyproject.toml
Normal file
36
pyproject.toml
Normal 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
8
requirements-test.txt
Normal 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
|
||||
@ -1,17 +1,25 @@
|
||||
#!/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
|
||||
if pytest --trace-config | grep -q "pytest_cov"; then
|
||||
export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}."
|
||||
|
||||
if python -c "import pytest_cov" 2>/dev/null; then
|
||||
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=$?
|
||||
echo "Coverage report (HTML) generated in htmlcov/index.html"
|
||||
echo "HTML coverage report: htmlcov/index.html"
|
||||
echo "XML coverage report: coverage.xml"
|
||||
exit $RET
|
||||
else
|
||||
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/
|
||||
fi
|
||||
|
||||
@ -2,14 +2,14 @@
|
||||
set -euo pipefail
|
||||
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 \
|
||||
--image=alpine:latest \
|
||||
--restart=Never \
|
||||
--rm --attach \
|
||||
--overrides='{
|
||||
"spec":{
|
||||
"nodeName":"gandalf.knoe.org",
|
||||
"nodeName":"gandalf.prole.org",
|
||||
"tolerations":[{"operator":"Exists"}],
|
||||
"volumes":[{"name":"synology","hostPath":{"path":"/synology/d005","type":"Directory"}}],
|
||||
"containers":[{
|
||||
|
||||
@ -26,7 +26,7 @@ spec:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values: [gandalf.knoe.org]
|
||||
values: [gandalf.prole.org]
|
||||
PVYAML
|
||||
|
||||
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 \
|
||||
--restart=Never \
|
||||
--overrides="{
|
||||
\"spec\":{\"nodeName\":\"gandalf.knoe.org\",\"tolerations\":[{\"operator\":\"Exists\"}],
|
||||
\"spec\":{\"nodeName\":\"gandalf.prole.org\",\"tolerations\":[{\"operator\":\"Exists\"}],
|
||||
\"containers\":[{\"name\":\"minio-init\",
|
||||
\"image\":\"quay.io/minio/mc:RELEASE.2022-10-20T23-30-35Z\",
|
||||
\"command\":[\"sh\",\"-c\",
|
||||
|
||||
@ -30,7 +30,7 @@ spec:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values: [gandalf.knoe.org]
|
||||
values: [gandalf.prole.org]
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
@ -50,7 +50,7 @@ spec:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: In
|
||||
values: [gandalf.knoe.org]
|
||||
values: [gandalf.prole.org]
|
||||
PVYAML
|
||||
|
||||
echo "==> Waiting 15s for binding..."
|
||||
|
||||
@ -25,8 +25,8 @@ export CLUSTER_NAME="knoe-db"
|
||||
|
||||
# Replicate milestones.py GitOpsMilestone.execute() lines 965-977
|
||||
export GITLAB_NAMESPACE="gitlab" # line 971: env["GITLAB_NAMESPACE"] = ns (ns="gitlab")
|
||||
export GITLAB_NODE_SELECTOR="gandalf.knoe.org" # line 976
|
||||
export NODE_SELECTOR="gandalf.knoe.org" # line 977
|
||||
export GITLAB_NODE_SELECTOR="gandalf.prole.org" # line 976
|
||||
export NODE_SELECTOR="gandalf.prole.org" # line 977
|
||||
|
||||
echo " Pre-call env: NAMESPACE='$NAMESPACE' GITLAB_NAMESPACE='$GITLAB_NAMESPACE'"
|
||||
echo ""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user