diff --git a/default_steps/step.bash.apply_package_updates.json b/default_steps/step.bash.apply_package_updates.json new file mode 100644 index 0000000..90d8826 --- /dev/null +++ b/default_steps/step.bash.apply_package_updates.json @@ -0,0 +1,12 @@ +{ + "kind": "vantage.step/v1", + "name": "Apply Package Updates", + "description": "Apply all pending OS package updates. Supports apt, dnf, yum, zypper, apk and pacman.", + "interpreter": "bash", + "script": "set -u\nif command -v apt-get >/dev/null 2>&1; then\n export DEBIAN_FRONTEND=noninteractive\n apt-get update -qq && apt-get -y -qq upgrade\nelif command -v dnf >/dev/null 2>&1; then\n dnf -y upgrade\nelif command -v yum >/dev/null 2>&1; then\n yum -y update\nelif command -v zypper >/dev/null 2>&1; then\n zypper --non-interactive update\nelif command -v apk >/dev/null 2>&1; then\n apk update && apk upgrade\nelif command -v pacman >/dev/null 2>&1; then\n pacman -Syu --noconfirm\nelse\n echo \"no supported package manager found\"\n exit 1\nfi\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"package update failed\"\n exit 1\nfi\necho \"packages up to date\"\n# Debian and Ubuntu drop this file when a new kernel or libc needs a restart.\n# Reported rather than acted on: rebooting a fleet is a decision, not a detail.\nif [ -f /var/run/reboot-required ]; then\n echo \"REBOOT_REQUIRED=true\" >> $WORKFLOW_ENV\n echo \"a reboot is required to finish applying updates\"\nelse\n echo \"REBOOT_REQUIRED=false\" >> $WORKFLOW_ENV\nfi", + "declared_outputs": [ + "REBOOT_REQUIRED" + ], + "declared_inputs": [], + "secret_refs": [] +} diff --git a/default_steps/step.bash.check_port.json b/default_steps/step.bash.check_port.json new file mode 100644 index 0000000..ed92c7b --- /dev/null +++ b/default_steps/step.bash.check_port.json @@ -0,0 +1,23 @@ +{ + "kind": "vantage.step/v1", + "name": "Check Port Is Listening", + "description": "Fail unless something is listening on a TCP port.", + "interpreter": "bash", + "script": "set -u\nhost=\"${host:-127.0.0.1}\"\nif command -v nc >/dev/null 2>&1; then\n nc -z -w 5 \"$host\" \"$port\" >/dev/null 2>&1\n ok=$?\nelse\n # bash builds /dev/tcp in, so this needs nothing installed.\n timeout 5 bash -c \"cat < /dev/null > /dev/tcp/$host/$port\" >/dev/null 2>&1\n ok=$?\nfi\nif [ $ok -ne 0 ]; then\n echo \"PORT_OPEN=false\" >> $WORKFLOW_ENV\n echo \"nothing listening on $host:$port\"\n exit 1\nfi\necho \"PORT_OPEN=true\" >> $WORKFLOW_ENV\necho \"$host:$port is open\"", + "declared_outputs": [ + "PORT_OPEN" + ], + "declared_inputs": [ + { + "name": "host", + "default": "127.0.0.1", + "description": "host to test" + }, + { + "name": "port", + "default": "", + "description": "TCP port to test" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.copy_path.json b/default_steps/step.bash.copy_path.json new file mode 100644 index 0000000..cadc87a --- /dev/null +++ b/default_steps/step.bash.copy_path.json @@ -0,0 +1,23 @@ +{ + "kind": "vantage.step/v1", + "name": "Copy File/Directory", + "description": "Copy a file or directory, preserving mode, ownership and timestamps.", + "interpreter": "bash", + "script": "set -u\nif [ ! -e \"$source\" ]; then\n echo \"source $source does not exist\"\n exit 1\nfi\ncp -a \"$source\" \"$destination\" || { echo \"failed to copy $source to $destination\"; exit 1; }\necho \"copied $source to $destination\"\necho \"DEST_PATH=$destination\" >> $WORKFLOW_ENV", + "declared_outputs": [ + "DEST_PATH" + ], + "declared_inputs": [ + { + "name": "source", + "default": "", + "description": "path to copy from" + }, + { + "name": "destination", + "default": "", + "description": "path to copy to" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.create_directory.json b/default_steps/step.bash.create_directory.json new file mode 100644 index 0000000..5459870 --- /dev/null +++ b/default_steps/step.bash.create_directory.json @@ -0,0 +1,23 @@ +{ + "kind": "vantage.step/v1", + "name": "Create Directory", + "description": "Create a directory, including any missing parents.", + "interpreter": "bash", + "script": "set -u\nmkdir -p \"$path\" || { echo \"failed to create $path\"; exit 1; }\nif [ -n \"${mode:-}\" ]; then\n chmod \"$mode\" \"$path\" || { echo \"failed to set mode $mode on $path\"; exit 1; }\nfi\necho \"created $path\"\necho \"DIR_PATH=$path\" >> $WORKFLOW_ENV", + "declared_outputs": [ + "DIR_PATH" + ], + "declared_inputs": [ + { + "name": "path", + "default": "", + "description": "directory to create" + }, + { + "name": "mode", + "default": "", + "description": "optional octal mode, e.g. 0750" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.delete_path.json b/default_steps/step.bash.delete_path.json new file mode 100644 index 0000000..3269312 --- /dev/null +++ b/default_steps/step.bash.delete_path.json @@ -0,0 +1,16 @@ +{ + "kind": "vantage.step/v1", + "name": "Delete File/Directory", + "description": "Delete a path. Refuses the root filesystem and an empty value.", + "interpreter": "bash", + "script": "set -u\n# A step that runs as root on every server in a selector has to refuse the\n# one input that would wipe the fleet. An unset variable expands to empty,\n# so the empty case is the accident this actually guards against.\ncase \"$path\" in\n \"\"|\"/\"|\"/.\"|\"/..\")\n echo \"refusing to delete '$path'\"\n exit 1\n ;;\nesac\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist, nothing to do\"\n exit 0\nfi\nrm -rf \"$path\" || { echo \"failed to delete $path\"; exit 1; }\necho \"deleted $path\"", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "path", + "default": "", + "description": "path to delete" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.disk_usage.json b/default_steps/step.bash.disk_usage.json new file mode 100644 index 0000000..1f8965c --- /dev/null +++ b/default_steps/step.bash.disk_usage.json @@ -0,0 +1,24 @@ +{ + "kind": "vantage.step/v1", + "name": "Disk Usage Report", + "description": "Report usage for a mount point and fail past a threshold.", + "interpreter": "bash", + "script": "set -u\nmount=\"${mountPoint:-/}\"\nlimit=\"${maxPercent:-90}\"\ndf -h \"$mount\"\nused=$(df --output=pcent \"$mount\" | tail -1 | tr -dc \"0-9\")\navail=$(df -h --output=avail \"$mount\" | tail -1 | tr -d \" \")\necho \"DISK_USED_PERCENT=$used\" >> $WORKFLOW_ENV\necho \"DISK_AVAILABLE=$avail\" >> $WORKFLOW_ENV\nif [ \"$used\" -ge \"$limit\" ]; then\n echo \"$mount is ${used}% full, at or over the ${limit}% limit\"\n exit 1\nfi\necho \"$mount is ${used}% full, ${avail} available\"", + "declared_outputs": [ + "DISK_USED_PERCENT", + "DISK_AVAILABLE" + ], + "declared_inputs": [ + { + "name": "mountPoint", + "default": "/", + "description": "mount point to measure" + }, + { + "name": "maxPercent", + "default": "90", + "description": "fail at or above this percentage" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.docker_compose_up.json b/default_steps/step.bash.docker_compose_up.json new file mode 100644 index 0000000..bdd04d8 --- /dev/null +++ b/default_steps/step.bash.docker_compose_up.json @@ -0,0 +1,16 @@ +{ + "kind": "vantage.step/v1", + "name": "Docker Compose Pull and Up", + "description": "Pull the latest images for a compose project and recreate its containers.", + "interpreter": "bash", + "script": "set -u\ncd \"$projectDir\" || { echo \"no such directory: $projectDir\"; exit 1; }\nif docker compose version >/dev/null 2>&1; then\n dc=\"docker compose\"\nelif command -v docker-compose >/dev/null 2>&1; then\n dc=\"docker-compose\"\nelse\n echo \"docker compose is not installed\"\n exit 1\nfi\n$dc pull || { echo \"pull failed\"; exit 1; }\n$dc up -d --remove-orphans || { echo \"up failed\"; exit 1; }\n$dc ps", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "projectDir", + "default": "", + "description": "directory holding docker-compose.yml" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.enable_linux_service.json b/default_steps/step.bash.enable_linux_service.json new file mode 100644 index 0000000..b003aa1 --- /dev/null +++ b/default_steps/step.bash.enable_linux_service.json @@ -0,0 +1,16 @@ +{ + "kind": "vantage.step/v1", + "name": "Enable Linux Service", + "description": "Enable a systemd unit so it starts on boot.", + "interpreter": "bash", + "script": "set -u\necho \"enabling service $serviceName\"\nsystemctl enable \"$serviceName\" || { echo \"failed to enable $serviceName\"; exit 1; }\necho \"$serviceName enabled\"", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "serviceName", + "default": "", + "description": "systemd unit to enable" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.extract_archive.json b/default_steps/step.bash.extract_archive.json new file mode 100644 index 0000000..c06807d --- /dev/null +++ b/default_steps/step.bash.extract_archive.json @@ -0,0 +1,23 @@ +{ + "kind": "vantage.step/v1", + "name": "Extract Archive", + "description": "Extract a tar, tar.gz, tar.bz2, tar.xz or zip archive into a directory.", + "interpreter": "bash", + "script": "set -u\ndest=\"${destination:-.}\"\nif [ ! -f \"$archive\" ]; then\n echo \"archive $archive does not exist\"\n exit 1\nfi\nmkdir -p \"$dest\"\ncase \"$archive\" in\n *.tar.gz|*.tgz) tar -xzf \"$archive\" -C \"$dest\" ;;\n *.tar.bz2|*.tbz2) tar -xjf \"$archive\" -C \"$dest\" ;;\n *.tar.xz|*.txz) tar -xJf \"$archive\" -C \"$dest\" ;;\n *.tar) tar -xf \"$archive\" -C \"$dest\" ;;\n *.zip)\n command -v unzip >/dev/null 2>&1 || { echo \"unzip is not installed\"; exit 1; }\n unzip -oq \"$archive\" -d \"$dest\"\n ;;\n *)\n echo \"unsupported archive type: $archive\"\n exit 1\n ;;\nesac\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to extract $archive\"\n exit 1\nfi\necho \"extracted $archive into $dest\"\necho \"EXTRACT_DIR=$dest\" >> $WORKFLOW_ENV", + "declared_outputs": [ + "EXTRACT_DIR" + ], + "declared_inputs": [ + { + "name": "archive", + "default": "", + "description": "archive file to extract" + }, + { + "name": "destination", + "default": ".", + "description": "directory to extract into" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.http_health_check.json b/default_steps/step.bash.http_health_check.json new file mode 100644 index 0000000..0de2b1c --- /dev/null +++ b/default_steps/step.bash.http_health_check.json @@ -0,0 +1,33 @@ +{ + "kind": "vantage.step/v1", + "name": "HTTP Health Check", + "description": "Request a URL and fail unless it answers with the expected status.", + "interpreter": "bash", + "script": "set -u\nexpected=\"${expectedStatus:-200}\"\nattempts=\"${retries:-3}\"\ndelay=\"${retryDelay:-5}\"\nstatus=\"\"\ni=1\n# Retries live in the script rather than in on_failure: a service coming up\n# after a restart wants a few seconds, not a whole step re-dispatched.\nwhile [ \"$i\" -le \"$attempts\" ]; do\n status=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$url\" || echo \"000\")\n echo \"attempt $i: $url returned $status\"\n if [ \"$status\" = \"$expected\" ]; then\n break\n fi\n i=$(( i + 1 ))\n if [ \"$i\" -le \"$attempts\" ]; then sleep \"$delay\"; fi\ndone\necho \"HTTP_STATUS=$status\" >> $WORKFLOW_ENV\nif [ \"$status\" != \"$expected\" ]; then\n echo \"$url returned $status, expected $expected\"\n exit 1\nfi\necho \"$url is healthy\"", + "declared_outputs": [ + "HTTP_STATUS" + ], + "declared_inputs": [ + { + "name": "url", + "default": "", + "description": "URL to request" + }, + { + "name": "expectedStatus", + "default": "200", + "description": "HTTP status that counts as healthy" + }, + { + "name": "retries", + "default": "3", + "description": "how many attempts before failing" + }, + { + "name": "retryDelay", + "default": "5", + "description": "seconds between attempts" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.memory_usage.json b/default_steps/step.bash.memory_usage.json new file mode 100644 index 0000000..b2230f2 --- /dev/null +++ b/default_steps/step.bash.memory_usage.json @@ -0,0 +1,13 @@ +{ + "kind": "vantage.step/v1", + "name": "Memory Usage Report", + "description": "Report memory usage as a percentage of total.", + "interpreter": "bash", + "script": "set -u\nfree -h\ntotal=$(free -m | awk \"/^Mem:/ {print \\$2}\")\nused=$(free -m | awk \"/^Mem:/ {print \\$3}\")\npct=$(( used * 100 / total ))\necho \"MEM_USED_PERCENT=$pct\" >> $WORKFLOW_ENV\necho \"MEM_USED_MB=$used\" >> $WORKFLOW_ENV\necho \"memory ${pct}% used (${used}MB of ${total}MB)\"", + "declared_outputs": [ + "MEM_USED_PERCENT", + "MEM_USED_MB" + ], + "declared_inputs": [], + "secret_refs": [] +} diff --git a/default_steps/step.bash.reboot_server.json b/default_steps/step.bash.reboot_server.json new file mode 100644 index 0000000..e9bb241 --- /dev/null +++ b/default_steps/step.bash.reboot_server.json @@ -0,0 +1,16 @@ +{ + "kind": "vantage.step/v1", + "name": "Reboot Server", + "description": "Schedule a reboot a minute out, so the step reports success before the machine goes down.", + "interpreter": "bash", + "script": "set -u\ndelay=\"${delayMinutes:-1}\"\n# Scheduled rather than immediate on purpose: `shutdown -r now` kills the\n# agent before it can report, and the run records a failure on a server\n# that did exactly what it was told.\necho \"rebooting in $delay minute(s)\"\nshutdown -r \"+$delay\" \"Reboot requested by Vantage\" || { echo \"failed to schedule a reboot\"; exit 1; }", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "delayMinutes", + "default": "1", + "description": "minutes to wait before rebooting" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.restart_linux_service.json b/default_steps/step.bash.restart_linux_service.json new file mode 100644 index 0000000..cf7dac7 --- /dev/null +++ b/default_steps/step.bash.restart_linux_service.json @@ -0,0 +1,16 @@ +{ + "kind": "vantage.step/v1", + "name": "Restart Linux Service", + "description": "Restart a systemd unit and fail if it does not come back up.", + "interpreter": "bash", + "script": "set -u\necho \"restarting service $serviceName\"\nsystemctl restart \"$serviceName\" || { echo \"failed to restart $serviceName\"; exit 1; }\nsystemctl is-active --quiet \"$serviceName\" || {\n echo \"$serviceName did not come back up\"\n systemctl status \"$serviceName\" --no-pager --lines=20 || true\n exit 1\n}\necho \"$serviceName is active\"", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "serviceName", + "default": "", + "description": "systemd unit to restart, e.g. nginx" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.service_status.json b/default_steps/step.bash.service_status.json new file mode 100644 index 0000000..7e8cf5a --- /dev/null +++ b/default_steps/step.bash.service_status.json @@ -0,0 +1,19 @@ +{ + "kind": "vantage.step/v1", + "name": "Linux Service Status", + "description": "Report whether a systemd unit is active and enabled. Does not fail on a stopped unit.", + "interpreter": "bash", + "script": "set -u\nstate=$(systemctl is-active \"$serviceName\" 2>/dev/null || true)\nenabled=$(systemctl is-enabled \"$serviceName\" 2>/dev/null || true)\necho \"$serviceName: state=$state enabled=$enabled\"\necho \"SERVICE_STATE=$state\" >> $WORKFLOW_ENV\necho \"SERVICE_ENABLED=$enabled\" >> $WORKFLOW_ENV", + "declared_outputs": [ + "SERVICE_STATE", + "SERVICE_ENABLED" + ], + "declared_inputs": [ + { + "name": "serviceName", + "default": "", + "description": "systemd unit to inspect" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.set_permissions.json b/default_steps/step.bash.set_permissions.json new file mode 100644 index 0000000..d116637 --- /dev/null +++ b/default_steps/step.bash.set_permissions.json @@ -0,0 +1,31 @@ +{ + "kind": "vantage.step/v1", + "name": "Set Permissions and Ownership", + "description": "Set the mode and optionally the owner of a path.", + "interpreter": "bash", + "script": "set -u\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist\"\n exit 1\nfi\nrecurse=\"\"\nif [ \"${recursive:-false}\" = \"true\" ]; then\n recurse=\"-R\"\nfi\nif [ -n \"${mode:-}\" ]; then\n chmod $recurse \"$mode\" \"$path\" || { echo \"failed to set mode\"; exit 1; }\n echo \"set mode $mode on $path\"\nfi\nif [ -n \"${owner:-}\" ]; then\n chown $recurse \"$owner\" \"$path\" || { echo \"failed to set owner\"; exit 1; }\n echo \"set owner $owner on $path\"\nfi", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "path", + "default": "", + "description": "path to change" + }, + { + "name": "mode", + "default": "", + "description": "octal mode, e.g. 0640" + }, + { + "name": "owner", + "default": "", + "description": "owner, e.g. root:root" + }, + { + "name": "recursive", + "default": "false", + "description": "true to apply recursively" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.tail_log.json b/default_steps/step.bash.tail_log.json new file mode 100644 index 0000000..ce286c6 --- /dev/null +++ b/default_steps/step.bash.tail_log.json @@ -0,0 +1,21 @@ +{ + "kind": "vantage.step/v1", + "name": "Tail Log File", + "description": "Print the last N lines of a file, for reading a log after a deployment step.", + "interpreter": "bash", + "script": "set -u\nlines=\"${lines:-50}\"\nif [ ! -f \"$path\" ]; then\n echo \"$path does not exist\"\n exit 1\nfi\necho \"last $lines lines of $path:\"\ntail -n \"$lines\" \"$path\"", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "path", + "default": "", + "description": "log file to read" + }, + { + "name": "lines", + "default": "50", + "description": "how many lines to print" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.bash.tls_expiry.json b/default_steps/step.bash.tls_expiry.json new file mode 100644 index 0000000..adca1a3 --- /dev/null +++ b/default_steps/step.bash.tls_expiry.json @@ -0,0 +1,29 @@ +{ + "kind": "vantage.step/v1", + "name": "TLS Certificate Expiry", + "description": "Report days remaining on a TLS certificate and fail under a threshold.", + "interpreter": "bash", + "script": "set -u\nport=\"${port:-443}\"\nmin=\"${minDays:-14}\"\ncommand -v openssl >/dev/null 2>&1 || { echo \"openssl is not installed\"; exit 1; }\n# -servername sends SNI, without which a shared host returns the wrong\n# certificate and the expiry reported here belongs to someone else.\nend=$(echo | openssl s_client -servername \"$host\" -connect \"$host:$port\" 2>/dev/null \\\n | openssl x509 -noout -enddate | cut -d= -f2)\nif [ -z \"$end\" ]; then\n echo \"could not read a certificate from $host:$port\"\n exit 1\nfi\nendEpoch=$(date -d \"$end\" +%s)\nnowEpoch=$(date +%s)\ndays=$(( (endEpoch - nowEpoch) / 86400 ))\necho \"CERT_DAYS_REMAINING=$days\" >> $WORKFLOW_ENV\necho \"CERT_EXPIRES=$end\" >> $WORKFLOW_ENV\necho \"$host:$port expires in $days days ($end)\"\nif [ \"$days\" -lt \"$min\" ]; then\n echo \"fewer than $min days remaining\"\n exit 1\nfi", + "declared_outputs": [ + "CERT_DAYS_REMAINING", + "CERT_EXPIRES" + ], + "declared_inputs": [ + { + "name": "host", + "default": "", + "description": "hostname to check" + }, + { + "name": "port", + "default": "443", + "description": "TLS port" + }, + { + "name": "minDays", + "default": "14", + "description": "fail below this many days remaining" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.powershell.download_file.json b/default_steps/step.powershell.download_file.json new file mode 100644 index 0000000..57b344f --- /dev/null +++ b/default_steps/step.powershell.download_file.json @@ -0,0 +1,23 @@ +{ + "kind": "vantage.step/v1", + "name": "Download File (Windows)", + "description": "Download a file over HTTP to a local path.", + "interpreter": "powershell", + "script": "$ErrorActionPreference = \"Stop\"\n$url = $env:url\n$dest = if ($env:destination) { $env:destination } else { Join-Path $env:TEMP ([System.IO.Path]::GetFileName($url)) }\nWrite-Output \"downloading $url\"\ntry {\n # -UseBasicParsing keeps this working on Server Core, where the IE\n # engine Invoke-WebRequest otherwise reaches for is not installed.\n Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing\n} catch {\n Write-Output \"failed to download: $_\"\n exit 1\n}\nWrite-Output \"saved to $dest\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"FILE_PATH=$dest\"", + "declared_outputs": [ + "FILE_PATH" + ], + "declared_inputs": [ + { + "name": "url", + "default": "", + "description": "URL to download" + }, + { + "name": "destination", + "default": "", + "description": "where to save it; defaults to a file in TEMP" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.powershell.reboot_windows_server.json b/default_steps/step.powershell.reboot_windows_server.json new file mode 100644 index 0000000..a2c9a80 --- /dev/null +++ b/default_steps/step.powershell.reboot_windows_server.json @@ -0,0 +1,16 @@ +{ + "kind": "vantage.step/v1", + "name": "Reboot Windows Server", + "description": "Schedule a reboot a minute out, so the step reports success before the machine goes down.", + "interpreter": "powershell", + "script": "$ErrorActionPreference = \"Stop\"\n$delay = if ($env:delaySeconds) { [int]$env:delaySeconds } else { 60 }\nWrite-Output \"rebooting in $delay second(s)\"\n& shutdown.exe /r /t $delay /c \"Reboot requested by Vantage\"\nif ($LASTEXITCODE -ne 0) {\n Write-Output \"failed to schedule a reboot\"\n exit 1\n}", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "delaySeconds", + "default": "60", + "description": "seconds to wait before rebooting" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.powershell.restart_windows_service.json b/default_steps/step.powershell.restart_windows_service.json new file mode 100644 index 0000000..0b6bcbb --- /dev/null +++ b/default_steps/step.powershell.restart_windows_service.json @@ -0,0 +1,16 @@ +{ + "kind": "vantage.step/v1", + "name": "Restart Windows Service", + "description": "Restart a Windows service and fail if it does not come back up.", + "interpreter": "powershell", + "script": "$ErrorActionPreference = \"Stop\"\n$name = $env:serviceName\nWrite-Output \"restarting service $name\"\ntry {\n Restart-Service -Name $name -Force\n} catch {\n Write-Output \"failed to restart ${name}: $_\"\n exit 1\n}\n$svc = Get-Service -Name $name\nif ($svc.Status -ne \"Running\") {\n Write-Output \"$name is $($svc.Status), not Running\"\n exit 1\n}\nWrite-Output \"$name is running\"", + "declared_outputs": [], + "declared_inputs": [ + { + "name": "serviceName", + "default": "", + "description": "Windows service name to restart" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.powershell.windows_disk_report.json b/default_steps/step.powershell.windows_disk_report.json new file mode 100644 index 0000000..8eb8386 --- /dev/null +++ b/default_steps/step.powershell.windows_disk_report.json @@ -0,0 +1,24 @@ +{ + "kind": "vantage.step/v1", + "name": "Windows Disk Report", + "description": "Report free space on a drive and fail past a usage threshold.", + "interpreter": "powershell", + "script": "$ErrorActionPreference = \"Stop\"\n$letter = if ($env:driveLetter) { $env:driveLetter } else { \"C\" }\n$limit = if ($env:maxPercent) { [int]$env:maxPercent } else { 90 }\n$d = Get-PSDrive -Name $letter -ErrorAction SilentlyContinue\nif ($null -eq $d) {\n Write-Output \"drive $letter not found\"\n exit 1\n}\n$total = $d.Used + $d.Free\n$pct = [math]::Round(($d.Used / $total) * 100)\n$freeGb = [math]::Round($d.Free / 1GB, 1)\nWrite-Output \"${letter}: is $pct% full, $freeGb GB free\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"DISK_USED_PERCENT=$pct\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"DISK_FREE_GB=$freeGb\"\nif ($pct -ge $limit) {\n Write-Output \"at or over the $limit% limit\"\n exit 1\n}", + "declared_outputs": [ + "DISK_USED_PERCENT", + "DISK_FREE_GB" + ], + "declared_inputs": [ + { + "name": "driveLetter", + "default": "C", + "description": "drive letter, without a colon" + }, + { + "name": "maxPercent", + "default": "90", + "description": "fail at or above this percentage" + } + ], + "secret_refs": [] +} diff --git a/default_steps/step.powershell.windows_service_status.json b/default_steps/step.powershell.windows_service_status.json new file mode 100644 index 0000000..07d123e --- /dev/null +++ b/default_steps/step.powershell.windows_service_status.json @@ -0,0 +1,19 @@ +{ + "kind": "vantage.step/v1", + "name": "Windows Service Status", + "description": "Report a Windows service's status and start type. Does not fail on a stopped service.", + "interpreter": "powershell", + "script": "$ErrorActionPreference = \"Stop\"\n$name = $env:serviceName\n$svc = Get-Service -Name $name -ErrorAction SilentlyContinue\nif ($null -eq $svc) {\n Write-Output \"$name is not installed\"\n Add-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_STATE=missing\"\n Add-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_START_TYPE=none\"\n exit 0\n}\nWrite-Output \"${name}: $($svc.Status), start type $($svc.StartType)\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_STATE=$($svc.Status)\"\nAdd-Content -Path $env:WORKFLOW_ENV -Value \"SERVICE_START_TYPE=$($svc.StartType)\"", + "declared_outputs": [ + "SERVICE_STATE", + "SERVICE_START_TYPE" + ], + "declared_inputs": [ + { + "name": "serviceName", + "default": "", + "description": "Windows service name to inspect" + } + ], + "secret_refs": [] +} diff --git a/web/app/(app)/steps/page.tsx b/web/app/(app)/steps/page.tsx index 9f7e5fd..df30c27 100644 --- a/web/app/(app)/steps/page.tsx +++ b/web/app/(app)/steps/page.tsx @@ -3,214 +3,234 @@ import { useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, WorkflowStep } from "@/lib/api"; -import { Button } from "@/components/ui"; +import { Button, Card } from "@/components/ui"; +import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; import { EditStepModal } from "@/components/workflows/EditStepModal"; type Tab = "all" | "bash" | "powershell" | "default" | "shared"; -const inputClass = - "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal"; +const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) { - const isBash = interpreter === "bash"; - return ( - - {isBash ? "bash" : "pwsh"} - - ); + const isBash = interpreter === "bash"; + return {isBash ? "bash" : "pwsh"}; } export default function StepsPage() { - const qc = useQueryClient(); - const { data: steps } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps }); - const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage }); + const qc = useQueryClient(); + const { data: steps, isLoading, error: loadError } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps }); + const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage }); - const [search, setSearch] = useState(""); - const [tab, setTab] = useState("all"); - const [editOpen, setEditOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [importing, setImporting] = useState(false); - const [syncing, setSyncing] = useState(false); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - const fileRef = useRef(null); + const [search, setSearch] = useState(""); + const [tab, setTab] = useState("all"); + const [editOpen, setEditOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [importing, setImporting] = useState(false); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const fileRef = useRef(null); - const rows = useMemo(() => { - const q = search.toLowerCase(); - return (steps ?? []).filter((s) => { - const matchesText = s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q); - const matchesTab = - tab === "all" || - (tab === "bash" && s.interpreter === "bash") || - (tab === "powershell" && s.interpreter === "powershell") || - (tab === "default" && s.source === "default") || - (tab === "shared" && s.source !== "default"); - return matchesText && matchesTab; - }); - }, [steps, search, tab]); + const rows = useMemo(() => { + const q = search.toLowerCase(); + return (steps ?? []).filter((s) => { + const matchesText = s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q); + const matchesTab = + tab === "all" || + (tab === "bash" && s.interpreter === "bash") || + (tab === "powershell" && s.interpreter === "powershell") || + (tab === "default" && s.source === "default") || + (tab === "shared" && s.source !== "default"); + return matchesText && matchesTab; + }); + }, [steps, search, tab]); - const openNew = () => { - setEditing(null); - setEditOpen(true); - }; - const openEdit = (s: WorkflowStep) => { - setEditing(s); - setEditOpen(true); - }; + const openNew = () => { + setEditing(null); + setEditOpen(true); + }; + const openEdit = (s: WorkflowStep) => { + setEditing(s); + setEditOpen(true); + }; - const onImport = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setImporting(true); - setError(null); - try { - const doc = JSON.parse(await file.text()); - await api.importStep(doc); - qc.invalidateQueries({ queryKey: ["steps"] }); - setNotice("Step imported."); - } catch (err) { - setError((err as Error).message); - } finally { - setImporting(false); - e.target.value = ""; - } - }; + const onImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setImporting(true); + setError(null); + try { + const doc = JSON.parse(await file.text()); + await api.importStep(doc); + qc.invalidateQueries({ queryKey: ["steps"] }); + setNotice("Step imported."); + } catch (err) { + setError((err as Error).message); + } finally { + setImporting(false); + e.target.value = ""; + } + }; - const onSync = async () => { - setSyncing(true); - setError(null); - try { - const { created, updated } = await api.seedDefaults(); - qc.invalidateQueries({ queryKey: ["steps"] }); - setNotice(`${created} created, ${updated} updated`); - } catch (err) { - setError((err as Error).message); - } finally { - setSyncing(false); - } - }; + const onSync = async () => { + setSyncing(true); + setError(null); + try { + const { created, updated } = await api.seedDefaults(); + qc.invalidateQueries({ queryKey: ["steps"] }); + setNotice(`${created} created, ${updated} updated`); + } catch (err) { + setError((err as Error).message); + } finally { + setSyncing(false); + } + }; - return ( -
-
-
-

Steps

-

Reusable steps shared across all workflows.

-
-
- - - - -
-
+ return ( +
+
+
+

Steps

+

+ {steps?.length ?? 0} step{steps?.length !== 1 ? "s" : ""} · reusable across all workflows +

+
+
+ + + + +
+
- {error &&
{error}
} - {notice &&
{notice}
} + {error &&
{error}
} + {notice &&
{notice}
} -
- setSearch(e.target.value)} /> -
- {(["all", "bash", "powershell", "default", "shared"] as Tab[]).map((t) => ( - - ))} -
-
- -
- - - - - - - - - - - - - {rows.map((s) => { - const count = usage?.[s.step_id] ?? 0; - return ( - - - - - - - - - ); - })} - {rows.length === 0 && ( - - - - )} - -
NameShellSourceOutputsUsed byActions
-
{s.name}
- {s.description &&
{s.description}
} -
- - - - {s.source === "default" ? "default" : "shared"} - - -
- {(s.declared_outputs ?? []).map((o) => ( - - {o} - - ))} -
-
- {count === 0 ? "0" : `${count} workflow${count === 1 ? "" : "s"}`} - -
- - - Export - - {s.source !== "default" && ( - - )} -
-
- No steps found. -
-
+ ))} +
+
- { - setEditOpen(false); - qc.invalidateQueries({ queryKey: ["steps"] }); - qc.invalidateQueries({ queryKey: ["step-usage"] }); - }} - /> - - ); + + {isLoading ? ( +
+
+
+ ) : loadError ? ( +
Failed to load steps. Is the backend running?
+ ) : rows.length > 0 ? ( + + + + + + + + + + + + {rows.map((s) => { + const count = usage?.[s.step_id] ?? 0; + return ( + + + + + + + + + ); + })} + +
NameShellSourceOutputsUsed by +
+ + {s.name} + {s.description && {s.description}} + + + + + + {s.source === "default" ? "default" : "shared"} + + + + {(s.declared_outputs ?? []).length === 0 ? ( + + ) : ( + (s.declared_outputs ?? []).map((o) => ( + + {o} + + )) + )} + + + {count === 0 ? "unused" : `${count} workflow${count === 1 ? "" : "s"}`} + +
+ + + + +
+
+ ) : ( +
+
+ + + +
+

{steps && steps.length > 0 ? "No steps match that filter." : "No steps yet."}

+ {(!steps || steps.length === 0) && ( + + )} +
+ )} + + + { + setEditOpen(false); + qc.invalidateQueries({ queryKey: ["steps"] }); + qc.invalidateQueries({ queryKey: ["step-usage"] }); + }} + /> +
+ ); }