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
431 lines
11 KiB
Text
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
|
|
}
|