2025-10-07 10:32:04 +01:00

420 lines
11 KiB
Plaintext

# Workspace Locking via Gitea Issues
#
# Distributed locking mechanism using Gitea issues
#
# Version: 1.0.0
use api_client.nu *
# 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 [] -> nothing {
let lock_repo = get-lock-repo
try {
get-repository $lock_repo.org $lock_repo.repo
} catch {
# 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
| filter {|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 | filter {|line| $line | str contains "Expiry:"}
if ($expiry_line | length) > 0 {
let expiry_str = $expiry_line.0 | str replace "- **Expiry**: " "" | str trim
let expiry = try {
$expiry_str | into datetime
} catch {
null
}
if ($expiry | is-not-empty) and ($expiry < $now) {
$lock
} else {
null
}
} else {
null
}
} | filter {|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
] -> any {
# Acquire lock
let lock = acquire-workspace-lock $workspace_name $lock_type $operation
# Execute command
let result = try {
do $command
} catch {|err|
# Release lock on error
release-workspace-lock $workspace_name $lock.lock_id
error make $err
}
# Release lock
release-workspace-lock $workspace_name $lock.lock_id
$result
}