prvng_core/nulib/lib_provisioning/gitea/locking.nu
Jesús Pérez e5ffc55104
refactor(23 files): selective imports + dangling/broken cleanup (ADR-025 L2/L3)
Large combined batch of 23 files refactored from star-imports to selective.
Grouped because two sub-batches accumulated in staging without intermediate
commit.

=== Orchestrator facades (Layer 3) ===
  ai/mod.nu              [12 symbols from ai/lib.nu]
  config/loader.nu       [14 symbols from loader/mod.nu]
  config/accessor/mod.nu [15 symbols from accessor/functions.nu]
  sops/mod.nu            [11 symbols from sops/lib.nu]
  user/mod.nu            [16 symbols from user/config.nu]

=== Selective imports ===
  defs/lists.nu                      utils/on_select (kept, selective)
  services/manager.nu                (all dead dropped)
  webhook/ai_webhook.nu              ai/lib [4] + settings/lib
  kms/lib.nu                         utils/error + utils/interface + plugins/kms
  gitea/locking.nu                   api_client [8]
  gitea/workspace_git.nu             api_client [3]
  gitea/extension_publish.nu         api_client [8] + config/loader
  infra_validator/rules_engine.nu    config_loader [3]
  plugins/kms.nu                     config/accessor/core [config-get]
  coredns/api_client.nu              config/loader [get-config]

=== Dangling imports removed (target file does not exist) ===
  coredns/docker.nu                  ../utils/log.nu → deleted (uses corefile.nu [2])
  coredns/zones.nu                   ../utils/log.nu → deleted (uses corefile.nu [1])
  coredns/service.nu                 ../utils/log.nu → deleted (uses corefile.nu [2])
  coredns/corefile.nu                ../utils/log.nu → deleted

=== Broken paths cleaned up ===
  project/detect.nu   Former `use ../../../lib_provisioning *` resolved to
    non-existent path (core/lib_provisioning). Silent no-op at runtime.
    Removed. Error count went 19 -> 17.

=== Dead imports dropped ===
  utils/ssh.nu           config/accessor DROPPED (dead)
  utils/init.nu          config/accessor DROPPED (dead)
  infra_validator/agent_interface.nu   report_generator DROPPED (dead)

=== Dynamic imports preserved ===
  providers/loader.nu   line 179 `use ($provider_entry.entry_point) *` is
    intentional runtime dispatch — not convertible to selective.

Validation: all files match pre-existing baseline. Gitea subsystem has
known pre-existing 50-error noise (transitive); independent of this work.

Refs: ADR-025
2026-04-17 12:13:13 +01:00

431 lines
11 KiB
Text

# Workspace Locking via Gitea Issues
#
# Distributed locking mechanism using Gitea issues
#
# Version: 1.0.0
# Selective imports (ADR-025 Phase 3 Layer 2).
use lib_provisioning/gitea/api_client.nu [
close-issue create-issue create-repository get-current-user
get-gitea-config get-issue get-repository list-issues
]
# Lock label constants
const LOCK_LABEL_PREFIX = "workspace-lock"
const READ_LOCK_LABEL = "read-lock"
const WRITE_LOCK_LABEL = "write-lock"
const DEPLOY_LOCK_LABEL = "deploy-lock"
# Get lock repository
def get-lock-repo [] -> record {
let config = get-gitea-config
let org = $config.repositories.workspaces_org
# Use special locks repository
{org: $org, repo: "workspace-locks"}
}
# Ensure locks repository exists
def ensure-lock-repo [] {
let lock_repo = get-lock-repo
let result = (do {
get-repository $lock_repo.org $lock_repo.repo
} | complete)
if $result.exit_code != 0 {
# Create locks repository
create-repository $lock_repo.org $lock_repo.repo "Workspace locking system" true false
print $"✓ Created locks repository: ($lock_repo.org)/($lock_repo.repo)"
}
}
# Format lock issue title
def format-lock-title [
workspace_name: string
lock_type: string
user: string
] -> string {
$"[LOCK:($lock_type)] ($workspace_name) by ($user)"
}
# Format lock issue body
def format-lock-body [
workspace_name: string
lock_type: string
user: string
operation?: string
expiry?: string
] -> string {
let timestamp = date now | format date "%Y-%m-%dT%H:%M:%SZ"
let body = [
"## Workspace Lock",
"",
$"- **Lock Type**: ($lock_type)",
$"- **Workspace**: ($workspace_name)",
$"- **User**: ($user)",
$"- **Timestamp**: ($timestamp)",
]
let body_with_operation = if ($operation | is-not-empty) {
$body | append [$"- **Operation**: ($operation)"]
} else {
$body
}
let body_with_expiry = if ($expiry | is-not-empty) {
$body_with_operation | append [$"- **Expiry**: ($expiry)"]
} else {
$body_with_operation
}
$body_with_expiry | str join "\n"
}
# Get lock labels
def get-lock-labels [
lock_type: string
] -> list {
let type_label = match $lock_type {
"read" => $READ_LOCK_LABEL,
"write" => $WRITE_LOCK_LABEL,
"deploy" => $DEPLOY_LOCK_LABEL,
_ => $lock_type
}
[$LOCK_LABEL_PREFIX, $type_label]
}
# Acquire workspace lock
export def acquire-workspace-lock [
workspace_name: string
lock_type: string # read, write, deploy
operation?: string
expiry?: string
--user: string = ""
] -> record {
# Get current user if not specified
let lock_user = if ($user | is-empty) {
(get-current-user).login
} else {
$user
}
# Ensure locks repository exists
ensure-lock-repo
let lock_repo = get-lock-repo
# Check for conflicting locks
let conflicts = check-lock-conflicts $workspace_name $lock_type
if ($conflicts | length) > 0 {
error make {
msg: $"Workspace ($workspace_name) is locked"
help: $"Conflicting locks: (($conflicts | each {|c| $"#($c.number)"} | str join ", "))"
}
}
# Create lock issue
let title = format-lock-title $workspace_name $lock_type $lock_user
let body = format-lock-body $workspace_name $lock_type $lock_user $operation $expiry
let labels = get-lock-labels $lock_type
let issue = create-issue $lock_repo.org $lock_repo.repo $title $body $labels
print $"✓ Workspace lock acquired: #($issue.number)"
{
lock_id: $issue.number
workspace: $workspace_name
lock_type: $lock_type
user: $lock_user
timestamp: (date now | format date "%Y-%m-%dT%H:%M:%SZ")
operation: $operation
expiry: $expiry
}
}
# Release workspace lock
export def release-workspace-lock [
workspace_name: string
lock_id: int
] -> bool {
let lock_repo = get-lock-repo
# Get lock issue
let issue = get-issue $lock_repo.org $lock_repo.repo $lock_id
# Verify it's a lock for this workspace
if not ($issue.title | str contains $workspace_name) {
error make {
msg: $"Lock #($lock_id) is not for workspace ($workspace_name)"
}
}
# Close issue
close-issue $lock_repo.org $lock_repo.repo $lock_id
print $"✓ Workspace lock released: #($lock_id)"
true
}
# Check lock conflicts
def check-lock-conflicts [
workspace_name: string
lock_type: string
] -> list {
let lock_repo = get-lock-repo
# Get all open locks for workspace
let all_locks = list-workspace-locks $workspace_name
# Check for conflicts
if $lock_type == "write" or $lock_type == "deploy" {
# Write/deploy locks conflict with any other lock
$all_locks
} else if $lock_type == "read" {
# Read locks only conflict with write/deploy locks
$all_locks | filter {|lock|
$lock.lock_type == "write" or $lock.lock_type == "deploy"
}
} else {
[]
}
}
# Check if workspace is locked
export def is-workspace-locked [
workspace_name: string
lock_type: string
] -> bool {
let conflicts = check-lock-conflicts $workspace_name $lock_type
($conflicts | length) > 0
}
# List active locks for workspace
export def list-workspace-locks [
workspace_name: string
] -> list {
let lock_repo = get-lock-repo
# Get all open issues with lock label
let issues = list-issues $lock_repo.org $lock_repo.repo "open" $LOCK_LABEL_PREFIX
# Filter for this workspace
$issues
| where {|issue| $issue.title | str contains $workspace_name}
| each {|issue|
# Parse lock info from issue
let lock_type = if ($issue.title | str contains "LOCK:write") {
"write"
} else if ($issue.title | str contains "LOCK:read") {
"read"
} else if ($issue.title | str contains "LOCK:deploy") {
"deploy"
} else {
"unknown"
}
# Extract user from title
let title_parts = $issue.title | split row " by "
let user = if ($title_parts | length) > 1 {
$title_parts.1
} else {
"unknown"
}
{
number: $issue.number
workspace: $workspace_name
lock_type: $lock_type
user: $user
created_at: $issue.created_at
title: $issue.title
url: $issue.html_url
}
}
}
# List all active locks
export def list-all-locks [] -> list {
let lock_repo = get-lock-repo
# Get all open issues with lock label
let issues = list-issues $lock_repo.org $lock_repo.repo "open" $LOCK_LABEL_PREFIX
$issues | each {|issue|
# Parse lock info from issue
let lock_type = if ($issue.title | str contains "LOCK:write") {
"write"
} else if ($issue.title | str contains "LOCK:read") {
"read"
} else if ($issue.title | str contains "LOCK:deploy") {
"deploy"
} else {
"unknown"
}
# Extract workspace and user from title
let title_parts = $issue.title | parse "[LOCK:{type}] {workspace} by {user}"
let parsed = if ($title_parts | length) > 0 {
$title_parts.0
} else {
{workspace: "unknown", user: "unknown"}
}
{
number: $issue.number
workspace: $parsed.workspace
lock_type: $lock_type
user: $parsed.user
created_at: $issue.created_at
title: $issue.title
url: $issue.html_url
}
}
}
# Force release lock (admin only)
export def force-release-lock [
workspace_name: string
lock_id: int
--reason: string = "Forced unlock"
] -> bool {
let lock_repo = get-lock-repo
# Get lock issue
let issue = get-issue $lock_repo.org $lock_repo.repo $lock_id
# Add comment about forced unlock
let current_user = (get-current-user).login
let comment_body = $"🔓 **Forced unlock by ($current_user)**\n\nReason: ($reason)"
# Note: Gitea API doesn't have a direct comment creation endpoint in basic API
# For now, just close the issue
close-issue $lock_repo.org $lock_repo.repo $lock_id
print $"⚠️ Force released lock #($lock_id) for workspace ($workspace_name)"
true
}
# Get lock info
export def get-lock-info [
workspace_name: string
lock_id: int
] -> record {
let lock_repo = get-lock-repo
let issue = get-issue $lock_repo.org $lock_repo.repo $lock_id
# Verify it's a lock for this workspace
if not ($issue.title | str contains $workspace_name) {
error make {
msg: $"Lock #($lock_id) is not for workspace ($workspace_name)"
}
}
# Parse lock type
let lock_type = if ($issue.title | str contains "LOCK:write") {
"write"
} else if ($issue.title | str contains "LOCK:read") {
"read"
} else if ($issue.title | str contains "LOCK:deploy") {
"deploy"
} else {
"unknown"
}
# Extract user from title
let title_parts = $issue.title | split row " by "
let user = if ($title_parts | length) > 1 {
$title_parts.1
} else {
"unknown"
}
{
lock_id: $issue.number
workspace: $workspace_name
lock_type: $lock_type
user: $user
created_at: $issue.created_at
updated_at: $issue.updated_at
state: $issue.state
title: $issue.title
body: $issue.body
url: $issue.html_url
labels: $issue.labels
}
}
# Cleanup expired locks
export def cleanup-expired-locks [] -> list {
let lock_repo = get-lock-repo
let now = date now
let all_locks = list-all-locks
# Find expired locks (based on expiry in body)
let expired = $all_locks | each {|lock|
let info = get-lock-info $lock.workspace $lock.number
# Parse expiry from body
let expiry_line = $info.body | lines | where {|line| $line | str contains "Expiry:"}
if ($expiry_line | length) > 0 {
let expiry_str = $expiry_line.0 | str replace "- **Expiry**: " "" | str trim
let expiry_result = (do {
$expiry_str | into datetime
} | complete)
let expiry = if $expiry_result.exit_code == 0 {
$expiry_result.stdout
} else {
null
}
if ($expiry | is-not-empty) and ($expiry < $now) {
$lock
} else {
null
}
} else {
null
}
} | where {|x| $x != null}
# Close expired locks
$expired | each {|lock|
close-issue $lock_repo.org $lock_repo.repo $lock.number
print $"✓ Closed expired lock: #($lock.number) for ($lock.workspace)"
$lock
}
}
# Auto-lock wrapper for operations
export def with-workspace-lock [
workspace_name: string
lock_type: string
operation: string
command: closure
] {
# Acquire lock
let lock = acquire-workspace-lock $workspace_name $lock_type $operation
# Execute command
let cmd_result = (do {
do $command
} | complete)
if $cmd_result.exit_code != 0 {
# Release lock on error
release-workspace-lock $workspace_name $lock.lock_id
error make {msg: $cmd_result.stderr}
}
# Release lock
release-workspace-lock $workspace_name $lock.lock_id
$cmd_result.stdout
}