cli: add support for GHA based update action

This commit is contained in:
Daylin Morgan 2024-09-09 16:04:48 -05:00
parent 3e0609d1f8
commit c450271582
Signed by: daylin
GPG key ID: 950D13E9719334AD
7 changed files with 202 additions and 34 deletions

View file

@ -1,5 +1,5 @@
## nix begat oizys ## nix begat oizys
import std/[os, tables, sequtils, strformat,] import std/[os, tables, sequtils, strformat,strutils]
import cligen, bbansi import cligen, bbansi
import oizys/[context, github, nix, overlay, logging] import oizys/[context, github, nix, overlay, logging]
@ -13,6 +13,17 @@ addHandler(
) )
) )
proc confirm(q: string): bool =
stderr.write $(q & bb"[yellow] (Y/n) ")
while true:
let ans = readLine(stdin)
case ans.strip().toLowerAscii():
of "y","yes": return true
of "n","no": return false
else:
stderr.write($bb("[red]Please answer Yes/no\nexpected one of [b]Y,yes,N,no "))
stderr.write "\n"
overlay: overlay:
proc pre( proc pre(
flake: string = "", flake: string = "",
@ -32,9 +43,20 @@ overlay:
## output ## output
echo nixosConfigAttrs().join(" ") echo nixosConfigAttrs().join(" ")
proc update(yes: bool = false) = proc update(
## *TBI* update and run nixos-rebuild yes: bool = false,
fatal "not implemented" preview: bool = false
) =
## update and run nixos-rebuild
let hosts = getHosts()
if hosts.len > 1: fatalQuit "operation only supports one host"
let run = getLastUpdateRun()
echo fmt"run created at: {run.created_at}"
echo "nvd diff:\n", getUpdateSummary(run.id, hosts[0])
if preview: quit 0
if yes or confirm("Proceed with system update?"):
updateRepo()
nixosRebuild("switch")
proc build(minimal: bool = false) = proc build(minimal: bool = false) =
## nix build ## nix build

View file

@ -1,11 +1,11 @@
import std/[logging, os, strformat, strutils] import std/[logging, os, strformat, strutils]
from std/nativesockets import getHostname from std/nativesockets import getHostname
import bbansi
import ./logging import ./logging
type type
OizysContext* = object OizysContext* = object
flake, host: string flake: string
hosts: seq[string] hosts: seq[string]
debug: bool debug: bool
ci: bool ci: bool
@ -39,13 +39,12 @@ proc updateContext*(
) = ) =
oc.debug = debug oc.debug = debug
oc.resetCache = resetCache oc.resetCache = resetCache
if host.len > 0:
oc.hosts = host
if flake != "": if flake != "":
oc.flake = oc.flake =
if flake.startsWith("github") or flake.startsWith("git+"): flake if flake.startsWith("github") or flake.startsWith("git+"): flake
else: checkPath(flake.normalizedPath().absolutePath()) else: checkPath(flake.normalizedPath().absolutePath())
debug oc
debug bb(fmt"""[b]flake[/]: {oc.flake}, [b]hosts[/]: {oc.hosts.join(" ")}""")
proc getHosts*(): seq[string] = return oc.hosts proc getHosts*(): seq[string] = return oc.hosts
proc getFlake*(): string = return oc.flake proc getFlake*(): string = return oc.flake

View file

@ -23,7 +23,7 @@ type
proc runCmdCapt*( proc runCmdCapt*(
cmd: string, cmd: string,
capture: set[CaptureGrp], capture: set[CaptureGrp] = {CaptStdout},
): tuple[stdout, stderr: string, exitCode: int] = ): tuple[stdout, stderr: string, exitCode: int] =
debug fmt"running cmd: {cmd}" debug fmt"running cmd: {cmd}"
let args = cmd.splitWhitespace() let args = cmd.splitWhitespace()

View file

@ -1,21 +1,43 @@
import std/[httpclient,logging, os, strformat, strutils, json] import std/[httpclient,logging, os, strformat, strutils, json, tables, tempfiles]
import ./logging import jsony, bbansi, zippy/ziparchives
import ./[logging, exec, context]
# localPassC is used by zippy but the additional
# module mangling on nixos somehow breaks localPassC
when defined(amd64) and (defined(gcc) or defined(clang)):
{.passC: "-msse4.1 -mpclmul".}
template withTmpDir(body: untyped): untyped =
let tmpDir {.inject.} = createTempDir("oizys","")
body
removeDir(tmpDir)
var ghToken = getEnv("GITHUB_TOKEN") var ghToken = getEnv("GITHUB_TOKEN")
proc checkToken() {.inline.} = proc checkToken() {.inline.} =
if ghToken == "": fatalQuit "GITHUB_TOKEN not set" if ghToken == "": fatalQuit "GITHUB_TOKEN not set"
#[curl -L \ proc ghClient(
-X POST \ maxRedirects = 5
-H "Accept: application/vnd.github+json" \ ): HttpClient =
-H "Authorization: Bearer <YOUR-TOKEN>" \ checkToken()
-H "X-GitHub-Api-Version: 2022-11-28" \ result = newHttpClient(maxRedirects = maxRedirects)
https://api.github.com/repos/OWNER/REPO/actions/workflows/WORKFLOW_ID/dispatches \ result.headers = newHttpHeaders({
-d '{"ref":"topic-branch","inputs":{"name":"Mona the Octocat","home":"San Francisco, CA"}}' "Accept" : "application/vnd.github+json",
]# "Authorization" : fmt"Bearer {ghToken}",
"X-GitHub-Api-Version": "2022-11-28",
})
proc getGhApi(url: string): Response =
let client = ghClient()
try:
result = client.get(url)
except:
error fmt"github api request failed: {url}"
error fmt"response: {result.body}"
quit QuitFailure
proc postGhApi(url: string, body: JsonNode) = proc postGhApi(url: string, body: JsonNode) =
checkToken() checkToken()
let client = newHttpClient() let client = newHttpClient()
@ -43,4 +65,129 @@ proc createDispatch*(workflowFileName: string, `ref`: string) =
} }
) )
type
GhArtifact = object
id: int
name: string
url: string
archive_download_url*: string
GhWorkflowRun = object
id*: int
node_id: string
run_number: int
event: string
status: string
conclusion: string
html_url: string
workflow_id: int
created_at*: string # use datetime?
updated_at: string # use datetime?
ListGhArtifactResponse = object
total_count: int
artifacts: seq[GhArtifact]
ListGhWorkflowResponse = object
total_count: int
workflow_runs: seq[GhWorkflowRun]
proc listUpdateRuns(): seq[GhWorkflowRun] =
## get update.yml runs
## endpoint https://api.github.com/repos/OWNER/REPO/actions/workflows/WORKFLOW_ID/runs
debug "listing update workflows"
let response = getGhApi("https://api.github.com/repos/daylinmorgan/oizys/actions/workflows/update.yml/runs")
fromJson(response.body, ListGhWorkflowResponse).workflow_runs
proc getLastUpdateRun*(): GhWorkflowRun =
let runs = listUpdateRuns()
let run = runs[0]
if run.conclusion == "failure":
fatalQuit bb(fmt("Most recent run was not successful\n[b]runID[/]: {run.id}\n[b]conclusion[/]: {run.conclusion}"))
if run.status in ["in_progress", "queued"]:
fatalQuit bb(fmt("Most recent run is not finished\nview workflow run at: {run.html_url}"))
result = run
proc getArtifacts(runId: int): seq[GhArtifact] =
## get workflow artifacts
## https://api.github.com/repos/OWNER/REPO/actions/runs/RUN_ID/artifacts
let response = getGhApi(fmt"https://api.github.com/repos/daylinmorgan/oizys/actions/runs/{runId}/artifacts")
fromJson(response.body, ListGhArtifactResponse).artifacts
proc getUpdateSummaryArtifact(runId: int, host: string): GhArtifact =
let name = fmt"{host}-summary"
let artifacts = getArtifacts(runId)
for artifact in artifacts:
if artifact.name == name:
return artifact
fatalQuit fmt"failed to find summary for run id: {runID}"
proc getUpdateSummaryUrl(runID: int, host: string): string =
## https://api.github.com/repos/OWNER/REPO/actions/artifacts/ARTIFACT_ID/ARCHIVE_FORMAT
let artifact = getUpdateSummaryArtifact(runID, host)
# httpclient was forwarding the Authorization headers,
# which confused Azure where the archive lives...
var response: Response
try:
let client = ghClient(maxRedirects = 0)
response = client.get(artifact.archive_download_url)
except:
errorQuit fmt("fetching summary failed:\n\n{response.headers}\n\n{response.body}")
if "location" notin response.headers.table:
errorQuit fmt("fetching summary failed:\n\n{response.headers}\n\n{response.body}")
let location = response.headers.table.getOrDefault("location", @[])
if location.len == 0: errorQuit fmt("location header missing url?")
return location[0]
proc fetchUpdateSummaryFromUrl(url: string): string =
withTmpDir:
let client = newHttpClient()
client.downloadFile(url, tmpDir / "summary.zip")
let reader = openZipArchive(tmpDir / "summary.zip")
try:
result = reader.extractFile("summary.md")
finally:
reader.close()
proc getUpdateSummary*(runId: int, host: string): string =
let url = getUpdateSummaryUrl(runId, host)
result = fetchUpdateSummaryFromUrl(url)
type
GitRepo = object
path: string
proc git(r: GitRepo, rest: varargs[string]): string =
result = "git"
result.addArgs ["-C", r.path]
result.addArgs rest
proc checkGit(code: int) =
if code != 0: fatalQuit "git had a non-zero exit status"
proc fetch(r: GitRepo) =
let code = runCmd r.git("fetch", "origin")
checkGit code
proc status(r: GitRepo) =
let (output, _, code) = runCmdCapt(r.git("status", "--porcelain"))
checkGit code
if output.len > 0:
info "unstaged commits, cowardly exiting..."
quit QuitFailure
proc rebase(r: GitRepo, `ref`: string) =
r.status()
let code = runCmd r.git("rebase", `ref`)
checkGit code
proc updateRepo*() =
let repo = GitRepo(path: getFlake())
fetch repo
rebase repo, "origin/flake-lock"

View file

@ -20,3 +20,4 @@ gitea
lock lock
code code
comma-with-db comma-with-db
nix-index-with-db

View file

@ -94,9 +94,10 @@ proc trunc(s: string, limit: int): string =
proc display(msg: string, drvs: seq[Derivation]) = proc display(msg: string, drvs: seq[Derivation]) =
echo fmt"{msg}: [bold cyan]{drvs.len()}[/]".bb echo fmt"{msg}: [bold cyan]{drvs.len()}[/]".bb
let maxLen = min(max drvs.mapIt(it.name.len), 40) if drvs.len > 0:
for drv in drvs: let maxLen = min(max drvs.mapIt(it.name.len), 40)
echo " ", drv.name.trunc(maxLen).alignLeft(maxLen), " ", drv.hash.bb("faint") for drv in drvs:
echo " ", drv.name.trunc(maxLen).alignLeft(maxLen), " ", drv.hash.bb("faint")
proc display(output: DryRunOutput) = proc display(output: DryRunOutput) =
if isDebug(): if isDebug():
@ -130,7 +131,7 @@ proc evaluateDerivations(drvs: seq[string]): Table[string, NixDerivation] =
fromJson(output, Table[string,NixDerivation]) fromJson(output, Table[string,NixDerivation])
# TODO: replace asserts in this proc # TODO: replace asserts in this proc, would be easier with results type
proc findSystemPaths(drvs: Table[string, NixDerivation]): seq[string] = proc findSystemPaths(drvs: Table[string, NixDerivation]): seq[string] =
let hosts = getHosts() let hosts = getHosts()
let systemDrvs = collect( let systemDrvs = collect(
@ -148,7 +149,8 @@ proc findSystemPaths(drvs: Table[string, NixDerivation]): seq[string] =
func isIgnored(drv: string): bool = func isIgnored(drv: string): bool =
const ignoredPackages = (slurp "ignored.txt").splitLines() const ignoredPackages = (slurp "ignored.txt").splitLines()
drv.split("-", 1)[1].replace(".drv","") in ignoredPackages let name = drv.split("-", 1)[1].replace(".drv","")
name in ignoredPackages
proc systemPathDrvsToBuild(): seq[string] = proc systemPathDrvsToBuild(): seq[string] =
let toBuild = toBuildNixosConfiguration() let toBuild = toBuildNixosConfiguration()
@ -157,11 +159,12 @@ proc systemPathDrvsToBuild(): seq[string] =
var inputDrvs: seq[string] var inputDrvs: seq[string]
for p in systemPaths: for p in systemPaths:
inputDrvs &= drvs[p].inputDrvs.keys().toSeq() inputDrvs &= drvs[p].inputDrvs.keys().toSeq()
result = collect( result = inputDrvs.filterIt(it in toBuild)
for drv in inputDrvs: let nToBuild = result.len
if (drv in toBuild) and (not drv.isIgnored()): result = result.filterIt(not it.isIgnored)
drv & "^*" let nIgnored = result.len - nToBuild
) debug fmt"ignored {nIgnored} derivations"
result = result.mapIt(it & "^*")
func splitDrv(drv: string): tuple[name, hash:string] = func splitDrv(drv: string): tuple[name, hash:string] =
let s = drv.split("-", 1) let s = drv.split("-", 1)

View file

@ -1,8 +1,4 @@
# oizys-nim todo's # oizys-nim todo's
- [x] nix commands including dry runs
- [ ] gh api commands
- [x] ci <- start with the easier one
- [ ] update
<!-- generated with <3 by daylinmorgan/todo --> <!-- generated with <3 by daylinmorgan/todo -->