Cross-platform binaries, MSI, service mgmt, pure-Go SQLite, centralized logging #3

Merged
gsadmin merged 10 commits from development into main 2026-09-02 13:13:19 +00:00
19 changed files with 788 additions and 240 deletions
+39
View File
@@ -0,0 +1,39 @@
# Keep the Docker build context small and reproducible. The image builds the
# frontend and Go binary from source inside the stages, so none of the local
# build output, dependencies, or VCS metadata should be shipped into the build.
# VCS / CI
.git
.gitea
.github
# Frontend: rebuilt by the image (npm ci + next build)
frontend/node_modules
frontend/.next
frontend/out
# Staged UI embed output: the image regenerates and stages this itself
backend/internal/webui/dist/*
!backend/internal/webui/dist/.gitignore
# Go / build artifacts
binaries
**/*.exe
**/*.test
# Local runtime data
data
**/*.db
**/*.db-shm
**/*.db-wal
**/*.log
# Local scratch space and docs (not needed to build the app)
Scratch
docs
# Editor / OS noise
.idea
.vscode
**/.DS_Store
**/Thumbs.db
+166 -72
View File
@@ -2,7 +2,8 @@ name: Release
# Fires only on a merge into main (a push to the main branch). No per-commit
# or per-PR CI runs on other branches — this is the single pipeline that turns
# what lands on main into a release + container image.
# what lands on main into a release, cross-platform binaries, an MSI, and a
# container image.
#
# Docs-only merges are skipped: touching just README/docs/LICENSE does not
# produce a new build.
@@ -20,9 +21,9 @@ on:
jobs:
release:
# Label must match a registered Linux runner that has Docker (used for the
# dockerized test gate and the image build/push). This is the same label the
# sibling repos use for their image jobs.
# Label must match a registered Linux runner that has Docker. Pure-Go builds
# (modernc SQLite, CGO off) mean every OS/arch cross-compiles here, and WiX v5
# builds the Windows MSI on Linux, so one runner produces every artifact.
runs-on: ubuntu-host
# Grant the auto-injected Actions token the scopes this job needs: push to
@@ -56,7 +57,7 @@ jobs:
# Version = the UTC date of the HEAD commit, formatted yyyy.MM.dd.HHmm
# (the project's documented version scheme). Deriving it from the commit
# rather than "now" makes re-runs reproducible and keeps the image tag,
# the release tag, and the binary's embedded version identical.
# the release tag, the binary version, and the MSI version aligned.
- name: Compute version
id: ver
shell: bash
@@ -66,38 +67,157 @@ jobs:
GIT_COMMIT="$(git rev-parse HEAD)"
GIT_COMMIT_SHORT="$(git rev-parse --short HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# MSI ProductVersion fields are bounded (major<=255, build<=65535), so
# yyyy.MM.dd.HHmm cannot be used directly. Map to major=yy, minor=month,
# build=day*1440+minute-of-day, which stays in range and increases
# monotonically over time for correct upgrade detection.
IFS=. read -r Y M D HM <<< "$VERSION"
Y=$((10#$Y)); M=$((10#$M)); D=$((10#$D)); HM=$((10#$HM))
HH=$((HM/100)); MM=$((HM%100))
MSI_VERSION="$((Y-2000)).$M.$((D*1440 + HH*60 + MM))"
{
echo "version=$VERSION"
echo "git_commit=$GIT_COMMIT"
echo "git_commit_short=$GIT_COMMIT_SHORT"
echo "build_time=$BUILD_TIME"
echo "msi_version=$MSI_VERSION"
echo "vy=$Y"; echo "vm=$M"; echo "vd=$D"; echo "vhm=$HM"
} >> "$GITHUB_OUTPUT"
echo "OrchestrAD version: $VERSION ($GIT_COMMIT_SHORT)"
echo "OrchestrAD version: $VERSION (msi $MSI_VERSION, $GIT_COMMIT_SHORT)"
# Test gate: run the Go test suite inside the same toolchain image the
# build uses. Running it in a container means the runner needs only Docker
# (no host Go/gcc), and a failure here stops the release before anything
# is published. CGO is on because the SQLite driver requires it.
- name: Test
shell: bash
run: |
set -euo pipefail
docker run --rm -v "$PWD/backend:/src" -w /src \
-e CGO_ENABLED=1 golang:1.24-alpine \
sh -c "apk add --no-cache gcc musl-dev >/dev/null && go test ./..."
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
# jq is used to JSON-encode the release body safely. Install it if the
# self-hosted runner does not already have it.
- name: Ensure tooling (jq)
shell: bash
run: |
set -euo pipefail
if ! command -v jq >/dev/null 2>&1; then
echo "Installing jq..."
sudo apt-get update -y && sudo apt-get install -y jq
fi
command -v jq >/dev/null 2>&1 || { sudo apt-get update -y && sudo apt-get install -y jq; }
jq --version
# Test gate: pure-Go, so no C toolchain is needed. A failure stops the
# release before anything is built or published.
- name: Test
working-directory: backend
env:
CGO_ENABLED: '0'
run: go test ./...
# Build the Next.js static export once on the host and stage it into the
# //go:embed dir so every standalone binary ships the real UI. (The
# container image builds its own copy via the Dockerfile.)
- name: Build frontend
working-directory: frontend
run: |
set -euo pipefail
npm ci --no-audit --no-fund
npm run build
- name: Stage UI into embed dir
shell: bash
run: |
set -euo pipefail
dist=backend/internal/webui/dist
find "$dist" -mindepth 1 -not -name '.gitignore' -delete
cp -r frontend/out/* "$dist"/
test -f "$dist/index.html"
# Generate the Windows icon/version resource. goversioninfo is pure Go; the
# arch-suffixed .syso files are only linked into their matching build.
- name: Generate Windows resource
working-directory: backend
env:
VY: ${{ steps.ver.outputs.vy }}
VM: ${{ steps.ver.outputs.vm }}
VD: ${{ steps.ver.outputs.vd }}
VHM: ${{ steps.ver.outputs.vhm }}
run: |
set -euo pipefail
go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest
GV="$(go env GOPATH)/bin/goversioninfo"
"$GV" -64 -ver-major="$VY" -ver-minor="$VM" -ver-patch="$VD" -ver-build="$VHM" \
-product-version="$VY.$VM.$VD" -o cmd/orchestrad/resource_windows_amd64.syso versioninfo.json
"$GV" -64 -arm -ver-major="$VY" -ver-minor="$VM" -ver-patch="$VD" -ver-build="$VHM" \
-product-version="$VY.$VM.$VD" -o cmd/orchestrad/resource_windows_arm64.syso versioninfo.json
# Cross-compile all six targets (CGO off) and package them: .zip for
# Windows, .tar.gz elsewhere, each with a .sha256. Stage the windows/amd64
# binary for the MSI.
- name: Build binaries
working-directory: backend
env:
VERSION: ${{ steps.ver.outputs.version }}
GIT_COMMIT: ${{ steps.ver.outputs.git_commit }}
BUILD_TIME: ${{ steps.ver.outputs.build_time }}
run: |
set -euo pipefail
LDFLAGS="-s -w \
-X github.com/Grace-Solutions/OrchestrAD/internal/version.Version=${VERSION} \
-X github.com/Grace-Solutions/OrchestrAD/internal/version.BuildTime=${BUILD_TIME} \
-X github.com/Grace-Solutions/OrchestrAD/internal/version.GitCommit=${GIT_COMMIT}"
DIST="$GITHUB_WORKSPACE/dist"; mkdir -p "$DIST" "$GITHUB_WORKSPACE/msistage"
build() {
local goos="$1" goarch="$2" label="$3" ext="$4"
echo "Building $label..."
local work; work="$(mktemp -d)"
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
go build -ldflags "$LDFLAGS" -o "$work/orchestrad$ext" ./cmd/orchestrad
local name="orchestrad-${VERSION}-${label}"
if [ "$goos" = "windows" ]; then
( cd "$work" && zip -q "$DIST/${name}.zip" "orchestrad$ext" )
( cd "$DIST" && sha256sum "${name}.zip" > "${name}.zip.sha256" )
else
( cd "$work" && tar -czf "$DIST/${name}.tar.gz" "orchestrad$ext" )
( cd "$DIST" && sha256sum "${name}.tar.gz" > "${name}.tar.gz.sha256" )
fi
if [ "$goos" = "windows" ] && [ "$goarch" = "amd64" ]; then
cp "$work/orchestrad.exe" "$GITHUB_WORKSPACE/msistage/orchestrad.exe"
fi
rm -rf "$work"
}
build windows amd64 windows-amd64 .exe
build windows arm64 windows-arm64 .exe
build darwin amd64 macos-amd64 ""
build darwin arm64 macos-arm64 ""
build linux amd64 linux-amd64 ""
build linux arm64 linux-arm64 ""
ls -la "$DIST"
# Build the Windows MSI with WiX v5 (cross-platform, no OSMF fee). Installs
# to Program Files\OrchestrAD and registers+starts the service via the
# binary's idempotent `initialize`/`remove` commands.
- name: Build MSI
env:
MSI_VERSION: ${{ steps.ver.outputs.msi_version }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$PATH:$HOME/.dotnet/tools"
command -v wix >/dev/null 2>&1 || dotnet tool install --global wix --version 5.0.2
wix build -arch x64 installer/OrchestrAD.wxs \
-d Version="$MSI_VERSION" \
-d BinDir="$GITHUB_WORKSPACE/msistage" \
-d IconPath="$GITHUB_WORKSPACE/resources/icons/orchestrad.ico" \
-o "dist/OrchestrAD-${VERSION}-x64.msi"
( cd dist && sha256sum "OrchestrAD-${VERSION}-x64.msi" > "OrchestrAD-${VERSION}-x64.msi.sha256" )
ls -la dist
- name: Resolve registry target
id: reg
shell: bash
@@ -151,24 +271,7 @@ jobs:
docker push "${IMAGE}:latest"
echo "Published ${IMAGE}:${VERSION} and ${IMAGE}:latest"
# Pull the linux/amd64 binary back out of the freshly built image so the
# release carries a ready-to-run artifact, not just an image reference.
- name: Extract release binary
env:
IMAGE: ${{ steps.reg.outputs.image }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
CID="$(docker create "${IMAGE}:${VERSION}")"
docker cp "$CID:/app/orchestrad" "dist/orchestrad"
docker rm "$CID" >/dev/null
tar -C dist -czf "dist/orchestrad-${VERSION}-linux-amd64.tar.gz" orchestrad
( cd dist && sha256sum "orchestrad-${VERSION}-linux-amd64.tar.gz" > "orchestrad-${VERSION}-linux-amd64.tar.gz.sha256" )
ls -la dist
# Create the Gitea release for this version and attach the binary. Uses
# the auto-injected token; no manual secret required.
# Create the Gitea release and attach every artifact in dist/.
- name: Create Gitea release
shell: bash
env:
@@ -182,44 +285,35 @@ jobs:
run: |
set -euo pipefail
# Skip if a release for this tag already exists (e.g. a re-run).
code="$(curl -sS -o /dev/null -w '%{http_code}' \
# Skip creation if a release for this tag already exists (re-run), but
# still (re)upload any assets that are missing below.
code="$(curl -sS -o /tmp/rel.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API_URL}/repos/${REPO}/releases/tags/${VERSION}")"
if [ "$code" = "200" ]; then
echo "Release ${VERSION} already exists; skipping."
exit 0
rel_id="$(jq -r '.id' /tmp/rel.json)"
echo "Release ${VERSION} already exists (id=$rel_id); ensuring assets."
else
body="$(printf '**OrchestrAD %s**\n\n| Field | Value |\n| --- | --- |\n| Version | `%s` |\n| Commit | [`%s`](%s/%s/commit/%s) |\n\n## Container image\n```\ndocker pull %s:%s\ndocker pull %s:latest\n```\n\n## Downloads\n- Windows installer: `OrchestrAD-%s-x64.msi` (installs to Program Files and runs as a service)\n- Standalone binaries: windows/macos/linux, amd64/arm64\n' \
"$VERSION" "$VERSION" "$GIT_COMMIT_SHORT" "$SERVER_URL" "$REPO" "$GIT_COMMIT" "$IMAGE" "$VERSION" "$IMAGE" "$VERSION")"
payload="$(jq -n --arg tag "$VERSION" --arg sha "$GIT_COMMIT" \
--arg name "OrchestrAD $VERSION" --arg body "$body" \
'{tag_name:$tag, target_commitish:$sha, name:$name, body:$body, draft:false, prerelease:false}')"
rel="$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" -d "$payload" \
"${API_URL}/repos/${REPO}/releases")"
rel_id="$(printf '%s' "$rel" | jq -r '.id')"
if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then
echo "Failed to create release:"; echo "$rel"; exit 1
fi
echo "Created release id=$rel_id"
fi
# Markdown release notes, JSON-encoded with jq so any characters are
# safely escaped.
body="$(printf '**OrchestrAD %s**\n\n| Field | Value |\n| --- | --- |\n| Version | `%s` |\n| Commit | [`%s`](%s/%s/commit/%s) |\n\n## Container image\n```\ndocker pull %s:%s\ndocker pull %s:latest\n```\n' \
"$VERSION" "$VERSION" "$GIT_COMMIT_SHORT" "$SERVER_URL" "$REPO" "$GIT_COMMIT" "$IMAGE" "$VERSION" "$IMAGE")"
payload="$(jq -n \
--arg tag "$VERSION" \
--arg sha "$GIT_COMMIT" \
--arg name "OrchestrAD $VERSION" \
--arg body "$body" \
'{tag_name:$tag, target_commitish:$sha, name:$name, body:$body, draft:false, prerelease:false}')"
rel="$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" \
"${API_URL}/repos/${REPO}/releases")"
rel_id="$(printf '%s' "$rel" | jq -r '.id')"
if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then
echo "Failed to create release:"; echo "$rel"; exit 1
fi
echo "Created release id=$rel_id"
for asset in dist/orchestrad-${VERSION}-linux-amd64.tar.gz dist/orchestrad-${VERSION}-linux-amd64.tar.gz.sha256; do
for asset in dist/*; do
name="$(basename "$asset")"
echo "Uploading $name"
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
-F "attachment=@${asset}" \
"${API_URL}/repos/${REPO}/releases/${rel_id}/assets?name=${name}" >/dev/null
done
echo "Release ${VERSION} published."
echo "Release ${VERSION} published with $(ls dist | wc -l) assets."
+4
View File
@@ -9,6 +9,10 @@
# Test binary
*.test
# Windows resource objects generated from versioninfo.json by goversioninfo
# (icon + version metadata). Regenerated during the build, not tracked.
*.syso
# Output of the go coverage tool
*.out
+3 -2
View File
@@ -25,7 +25,8 @@ RUN npm run build
# ---------------------------------------------------------------------------
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git gcc musl-dev
# Pure-Go SQLite (modernc.org/sqlite) means no C toolchain is needed.
RUN apk add --no-cache git
WORKDIR /build
@@ -44,7 +45,7 @@ ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG GIT_COMMIT=unknown
RUN CGO_ENABLED=1 go build \
RUN CGO_ENABLED=0 go build \
-ldflags "-s -w \
-X github.com/Grace-Solutions/OrchestrAD/internal/version.Version=${VERSION} \
-X github.com/Grace-Solutions/OrchestrAD/internal/version.BuildTime=${BUILD_TIME} \
+51 -59
View File
@@ -7,6 +7,7 @@ import (
"os"
"github.com/Grace-Solutions/OrchestrAD/internal/cli"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/version"
)
@@ -18,70 +19,59 @@ func main() {
cmd := os.Args[1]
// version and help are plain CLI output, not log events, so they bypass the
// centralized logger. Everything else dispatches to a handler and routes any
// error through the centralized logger for one consistent format.
switch cmd {
case "version", "-v", "--version":
fmt.Printf("OrchestrAD %s\n", version.Version)
case "init":
if err := cli.RunInit(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "run":
if err := cli.RunForeground(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "install":
if err := cli.RunInstall(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "uninstall":
if err := cli.RunUninstall(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "start":
if err := cli.RunStart(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "stop":
if err := cli.RunStop(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "migrate":
if err := cli.RunMigrate(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "backup":
if err := cli.RunBackup(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "restore":
if len(os.Args) < 4 || os.Args[2] != "--file" {
fmt.Fprintf(os.Stderr, "Usage: orchestrad restore --file <path>\n")
os.Exit(1)
}
if err := cli.RunRestore(os.Args[3]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "doctor":
if err := cli.RunDoctor(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
case "help", "-h", "--help":
printUsage()
return
}
var run func() error
switch cmd {
case "init":
run = cli.RunInit
case "run":
run = cli.RunForeground
case "initialize":
run = cli.RunInitialize
case "remove":
run = cli.RunRemove
case "install":
run = cli.RunInstall
case "uninstall":
run = cli.RunUninstall
case "start":
run = cli.RunStart
case "stop":
run = cli.RunStop
case "migrate":
run = cli.RunMigrate
case "backup":
run = cli.RunBackup
case "restore":
if len(os.Args) < 4 || os.Args[2] != "--file" {
logging.Error("CLI", "Usage: orchestrad restore --file <path>")
os.Exit(1)
}
path := os.Args[3]
run = func() error { return cli.RunRestore(path) }
case "doctor":
run = cli.RunDoctor
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
logging.Error("CLI", "Unknown command: %s", cmd)
printUsage()
os.Exit(1)
}
if err := run(); err != nil {
logging.Error("CLI", "%v", err)
os.Exit(1)
}
}
func printUsage() {
@@ -90,10 +80,12 @@ func printUsage() {
Usage: orchestrad <command> [options]
Commands:
init Initialize database, apply migrations, install and start service
run Run in foreground mode
install Install as system service
uninstall Stop and uninstall service
init Initialize database and apply migrations
run Run in foreground mode (also the service entry point)
initialize Install and start the service (idempotent)
remove Stop and remove the service (idempotent)
install Alias for initialize
uninstall Alias for remove
start Start installed service
stop Stop installed service
migrate Apply database migrations
+11 -5
View File
@@ -1,8 +1,6 @@
module github.com/Grace-Solutions/OrchestrAD
go 1.24.0
toolchain go1.24.2
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.0.12
@@ -10,17 +8,25 @@ require (
github.com/go-ldap/ldap/v3 v3.4.13
github.com/golang-migrate/migrate/v4 v4.17.0
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/kardianos/service v1.3.0
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.48.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
modernc.org/sqlite v1.58.0
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+50 -4
View File
@@ -5,6 +5,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1L
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
@@ -15,6 +17,8 @@ github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baD
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU=
github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -24,6 +28,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -36,12 +42,18 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -52,11 +64,45 @@ go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+52 -33
View File
@@ -5,10 +5,7 @@ import (
"context"
"crypto/sha256"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
"github.com/Grace-Solutions/OrchestrAD/internal/auth"
@@ -31,7 +28,7 @@ func RunInit() error {
return fmt.Errorf("loading config: %w", err)
}
logger := logging.New(cfg.Logging)
logger := logging.Init(cfg.Logging)
logger.Info("CLI", "Initializing OrchestrAD...")
// Initialize database
@@ -54,15 +51,28 @@ func RunInit() error {
return nil
}
// RunForeground runs the application in foreground mode
// RunForeground runs the application. It always goes through the service
// framework (service.Run): interactively this starts the server and blocks
// until an interrupt; under a service manager (Windows SCM, systemd, launchd)
// it speaks the manager's control protocol. Docker and manual `run` invocations
// take the interactive path; the installed service launches `run` too, so both
// share exactly one startup path.
func RunForeground() error {
return runService()
}
// runServer contains the actual server bring-up: config, database, migrations,
// bootstrap, services, scheduler, and the HTTP server. It blocks until ctx is
// cancelled (by an interrupt when interactive, or by the service manager's stop
// request), then returns after a graceful shutdown.
func runServer(ctx context.Context) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
logger := logging.New(cfg.Logging)
logger.Info("CLI", "Starting OrchestrAD in foreground mode...")
logger := logging.Init(cfg.Logging)
logger.Info("CLI", "Starting OrchestrAD...")
// Initialize database
database, err := db.New(cfg.Database, logger)
@@ -116,50 +126,59 @@ func RunForeground() error {
"mode": "foreground",
})
// Setup graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start the scheduler so enabled rules fire automatically
// Start the scheduler so enabled rules fire automatically. It is bound to
// the run context so it stops when the server is asked to shut down.
sched := scheduler.New(database.Conn(), ruleRunner, logger)
if err := sched.Start(ctx); err != nil {
return fmt.Errorf("starting scheduler: %w", err)
}
defer sched.Stop()
// Create HTTP server
// Create and run the HTTP server. srv.Run blocks until ctx is cancelled,
// which happens on an interactive interrupt or a service stop request.
srv := server.New(cfg, database, deps, logger)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
logger.Info("CLI", "Shutdown signal received, stopping server...")
cancel()
}()
return srv.Run(ctx)
}
// RunInstall installs the application as a system service
// RunInitialize idempotently installs and starts the system service (Windows
// service, systemd/upstart/sysv unit, or launchd daemon). Safe to re-run.
func RunInitialize() error {
return initializeService()
}
// RunRemove idempotently stops and removes the system service. Safe to re-run.
func RunRemove() error {
return removeService()
}
// RunInstall is an alias for RunInitialize: it idempotently installs and starts
// the service.
func RunInstall() error {
return fmt.Errorf("service install not yet implemented for this platform")
return initializeService()
}
// RunUninstall removes the system service
// RunUninstall is an alias for RunRemove: it idempotently stops and removes the
// service.
func RunUninstall() error {
return fmt.Errorf("service uninstall not yet implemented for this platform")
return removeService()
}
// RunStart starts the installed service
// RunStart starts the installed service.
func RunStart() error {
return fmt.Errorf("service start not yet implemented for this platform")
if err := controlService("start"); err != nil {
return err
}
logging.Info("Service", "Service started")
return nil
}
// RunStop stops the installed service
// RunStop stops the installed service.
func RunStop() error {
return fmt.Errorf("service stop not yet implemented for this platform")
if err := controlService("stop"); err != nil {
return err
}
logging.Info("Service", "Service stopped")
return nil
}
// RunMigrate applies database migrations
@@ -169,7 +188,7 @@ func RunMigrate() error {
return fmt.Errorf("loading config: %w", err)
}
logger := logging.New(cfg.Logging)
logger := logging.Init(cfg.Logging)
logger.Info("CLI", "Running database migrations...")
database, err := db.New(cfg.Database, logger)
@@ -203,7 +222,7 @@ func RunDoctor() error {
return fmt.Errorf("config validation failed: %w", err)
}
logger := logging.New(cfg.Logging)
logger := logging.Init(cfg.Logging)
logger.Info("Doctor", "Running system health checks...")
// Check database
+175
View File
@@ -0,0 +1,175 @@
// Service lifecycle management (install / uninstall / start / stop) plus the
// service-hosted run loop. Uses kardianos/service so the same binary runs as a
// Windows service (SCM), a systemd/upstart/sysv daemon on Linux, or a launchd
// daemon on macOS, and can also run interactively (foreground / container).
package cli
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/kardianos/service"
)
const (
serviceName = "OrchestrAD"
serviceDisplayName = "OrchestrAD"
serviceDescription = "OrchestrAD - Active Directory Rule Automation Platform"
)
// program adapts the server run loop to the service.Interface contract. Start
// must not block, so the server runs on a goroutine whose lifetime is bound to
// a context cancelled by Stop.
type program struct {
cancel context.CancelFunc
done chan error
}
// Start is called by the service manager (or by Run when interactive). It
// launches the server without blocking.
func (p *program) Start(s service.Service) error {
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
p.done = make(chan error, 1)
go func() {
p.done <- runServer(ctx)
}()
return nil
}
// Stop is called on service stop / interactive interrupt. It cancels the run
// context and waits for the server to unwind so the process exits cleanly.
func (p *program) Stop(s service.Service) error {
if p.cancel != nil {
p.cancel()
}
if p.done != nil {
return <-p.done
}
return nil
}
// serviceConfig builds the platform service definition. WorkingDirectory is
// pinned to the executable's directory so a relative ORCHESTRAD_DATA_PATH
// (default ./data) resolves next to the installed binary rather than wherever
// the service manager happens to launch it from. Arguments ["run"] make the
// service manager start the process in run mode, which routes back through
// service.Run.
func serviceConfig() (*service.Config, error) {
exe, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolving executable path: %w", err)
}
return &service.Config{
Name: serviceName,
DisplayName: serviceDisplayName,
Description: serviceDescription,
Arguments: []string{"run"},
WorkingDirectory: filepath.Dir(exe),
}, nil
}
// newService constructs the service handle and its program.
func newService() (service.Service, error) {
cfg, err := serviceConfig()
if err != nil {
return nil, err
}
return service.New(&program{}, cfg)
}
// runService runs the server through the service framework. When launched by a
// service manager it speaks the manager's control protocol (required on
// Windows); when launched interactively it runs Start, blocks until an
// interrupt, then runs Stop. This is the single entry point used by the `run`
// command so foreground, container, and service execution share one path.
func runService() error {
s, err := newService()
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
return s.Run()
}
// controlService applies a single service control action (start or stop) using
// the platform service manager.
func controlService(action string) error {
s, err := newService()
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
if err := service.Control(s, action); err != nil {
return fmt.Errorf("%s service: %w", action, err)
}
return nil
}
// initializeService idempotently ensures the service is installed and running.
// Re-running it is safe: an already-installed service is not reinstalled, and an
// already-running one is left alone. This backs the `initialize` (and `install`)
// command so provisioning is repeatable.
func initializeService() error {
s, err := newService()
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
status, err := s.Status()
if errors.Is(err, service.ErrNotInstalled) {
if err := s.Install(); err != nil {
return fmt.Errorf("installing service: %w", err)
}
logging.Info("Service", "Service installed")
status = service.StatusStopped
} else if err != nil {
return fmt.Errorf("querying service status: %w", err)
} else {
logging.Info("Service", "Service already installed")
}
if status == service.StatusRunning {
logging.Info("Service", "Service already running")
return nil
}
if err := s.Start(); err != nil {
return fmt.Errorf("starting service: %w", err)
}
logging.Info("Service", "Service started")
return nil
}
// removeService idempotently ensures the service is stopped and removed.
// Re-running it is safe: a not-installed service is treated as already removed,
// and a stop failure on an already-stopped service does not block removal. This
// backs the `remove` (and `uninstall`) command.
func removeService() error {
s, err := newService()
if err != nil {
return fmt.Errorf("creating service: %w", err)
}
status, err := s.Status()
if errors.Is(err, service.ErrNotInstalled) {
logging.Info("Service", "Service not installed; nothing to remove")
return nil
} else if err != nil {
return fmt.Errorf("querying service status: %w", err)
}
if status == service.StatusRunning {
if err := s.Stop(); err != nil {
logging.Warn("Service", "Could not stop service before removal: %v", err)
} else {
logging.Info("Service", "Service stopped")
}
}
if err := s.Uninstall(); err != nil {
return fmt.Errorf("removing service: %w", err)
}
logging.Info("Service", "Service removed")
return nil
}
+9 -7
View File
@@ -9,9 +9,9 @@ import (
"github.com/Grace-Solutions/OrchestrAD/internal/config"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/mattn/go-sqlite3"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
@@ -28,10 +28,12 @@ type DB struct {
func New(cfg config.DatabaseConfig, logger *logging.Logger) (*DB, error) {
logger.Info("Database", "Opening database at %s", cfg.Path)
// Build connection string with pragmas
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000", cfg.Path)
// Build connection string with pragmas. modernc.org/sqlite applies these
// _pragma directives on every connection the pool opens, so WAL mode,
// foreign-key enforcement, and the busy timeout hold for all connections.
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=busy_timeout(5000)", cfg.Path)
conn, err := sql.Open("sqlite3", dsn)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
@@ -77,13 +79,13 @@ func (db *DB) Migrate() error {
}
// Create migration driver
driver, err := sqlite3.WithInstance(db.conn, &sqlite3.Config{})
driver, err := sqlite.WithInstance(db.conn, &sqlite.Config{})
if err != nil {
return fmt.Errorf("creating migration driver: %w", err)
}
// Create migrator
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
m, err := migrate.NewWithInstance("iofs", source, "sqlite", driver)
if err != nil {
return fmt.Errorf("creating migrator: %w", err)
}
+48
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/config"
@@ -24,6 +25,53 @@ type Logger struct {
writer io.Writer
}
// std is the process-wide centralized logger. Every entry point initializes it
// once via Init and shares the returned instance through dependency injection;
// code that cannot easily receive an injected logger (main, the service
// wrapper) uses the package-level Default/Info/Warn/Error/Debug helpers, which
// resolve to this same instance. This keeps all output on one writer, one
// rotation policy, and the single [UTC] - [Component] - [Level] - Message format.
var (
stdMu sync.RWMutex
std *Logger
)
// Init creates the centralized logger from cfg, stores it as the process-wide
// default, and returns it so callers can also inject it explicitly. The last
// Init wins; typically it is called exactly once per process at startup.
func Init(cfg config.LoggingConfig) *Logger {
l := New(cfg)
stdMu.Lock()
std = l
stdMu.Unlock()
return l
}
// Default returns the centralized logger. If Init has not run yet (e.g. an
// early top-level error before config is loaded), it lazily creates a
// console-only logger so logging never panics and the format stays consistent.
func Default() *Logger {
stdMu.RLock()
l := std
stdMu.RUnlock()
if l != nil {
return l
}
stdMu.Lock()
defer stdMu.Unlock()
if std == nil {
std = New(config.LoggingConfig{Level: "info", EnableConsole: true})
}
return std
}
// Package-level helpers delegate to the centralized logger so callers without
// an injected Logger still emit the standard format.
func Debug(component, msg string, args ...any) { Default().Debug(component, msg, args...) }
func Info(component, msg string, args ...any) { Default().Info(component, msg, args...) }
func Warn(component, msg string, args ...any) { Default().Warn(component, msg, args...) }
func Error(component, msg string, args ...any) { Default().Error(component, msg, args...) }
// New creates a new Logger with the given configuration
func New(cfg config.LoggingConfig) *Logger {
level := parseLevel(cfg.Level)
+7 -1
View File
@@ -109,9 +109,14 @@ func (s *Server) setupMiddleware() {
}
func (s *Server) setupRoutes() {
// Health check (unauthenticated)
// Health check (unauthenticated). Register HEAD as well as GET: container
// health probes and reverse proxies commonly issue HEAD (e.g. the Docker
// HEALTHCHECK's `wget --spider`), and chi returns 405 for an unregistered
// method rather than falling back to the GET handler.
s.router.Get("/health", s.handleHealth)
s.router.Head("/health", s.handleHealth)
s.router.Get("/api/health", s.handleHealth)
s.router.Head("/api/health", s.handleHealth)
// API v1 routes
s.router.Route("/api/v1", func(r chi.Router) {
@@ -120,6 +125,7 @@ func (s *Server) setupRoutes() {
// interrogate the server before any user has signed in.
r.Get("/version", s.handleVersion)
r.Get("/health", s.handleHealth)
r.Head("/health", s.handleHealth)
// Auth endpoints. Login / logout / csrf are public by design; /me
// requires a valid session so clients can resolve the current user.
+25
View File
@@ -0,0 +1,25 @@
{
"FixedFileInfo": {
"FileVersion": { "Major": 0, "Minor": 0, "Patch": 0, "Build": 0 },
"ProductVersion": { "Major": 0, "Minor": 0, "Patch": 0, "Build": 0 },
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "040004",
"FileType": "01",
"FileSubType": "00"
},
"StringFileInfo": {
"CompanyName": "Grace Solutions",
"FileDescription": "OrchestrAD - Active Directory Rule Automation Platform",
"InternalName": "orchestrad",
"LegalCopyright": "Copyright (c) Grace Solutions",
"OriginalFilename": "orchestrad.exe",
"ProductName": "OrchestrAD",
"ProductVersion": "dev"
},
"VarFileInfo": {
"Translation": { "LangID": "0409", "CharsetID": "04B0" }
},
"IconPath": "../resources/icons/orchestrad.ico",
"ManifestPath": ""
}
+78
View File
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
OrchestrAD Windows installer (WiX v4+ / built with WiX 7).
Installs orchestrad.exe to "Program Files\OrchestrAD" and registers +
starts the Windows service by invoking the binary's own idempotent
`initialize` command. Uninstall runs `remove` (idempotent stop + delete)
before the files are deleted, so no orphaned service is left behind.
Build:
wix build installer/OrchestrAD.wxs \
-d Version=<x.y.z> -d BinDir=<dir containing orchestrad.exe> \
-d IconPath=<path to orchestrad.ico> -o OrchestrAD.msi
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="OrchestrAD"
Manufacturer="Grace Solutions"
Version="$(Version)"
UpgradeCode="41EC0251-FC95-4C00-8C91-EC3F9BE1F278"
Scope="perMachine"
Compressed="yes">
<SummaryInformation Description="OrchestrAD - Active Directory Rule Automation Platform" />
<!-- Block downgrades; replace older/equal on upgrade. -->
<MajorUpgrade DowngradeErrorMessage="A newer version of OrchestrAD is already installed." />
<MediaTemplate EmbedCab="yes" />
<!-- Add/Remove Programs metadata. -->
<Icon Id="OrchestradIcon" SourceFile="$(IconPath)" />
<Property Id="ARPPRODUCTICON" Value="OrchestradIcon" />
<Property Id="ARPNOMODIFY" Value="1" />
<!-- Install tree: Program Files\OrchestrAD -->
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="OrchestrAD" />
</StandardDirectory>
<ComponentGroup Id="ProductComponents" Directory="INSTALLFOLDER">
<Component Id="OrchestradExe" Bitness="always64">
<File Id="OrchestradExe" Name="orchestrad.exe" Source="$(BinDir)/orchestrad.exe" KeyPath="yes" />
</Component>
</ComponentGroup>
<Feature Id="Main" Title="OrchestrAD" Level="1">
<ComponentGroupRef Id="ProductComponents" />
</Feature>
<!--
Service lifecycle via the binary's own idempotent CLI. Deferred, no-impersonate
custom actions run as LocalSystem, which can create/delete services.
- Install/upgrade: after the files land, run `initialize` (install + start).
- Uninstall: before the files are removed, run `remove` (stop + delete).
-->
<CustomAction Id="InitializeService"
FileRef="OrchestradExe"
ExeCommand="initialize"
Execute="deferred"
Impersonate="no"
Return="check" />
<CustomAction Id="RemoveService"
FileRef="OrchestradExe"
ExeCommand="remove"
Execute="deferred"
Impersonate="no"
Return="ignore" />
<InstallExecuteSequence>
<!-- Remove the service before its binary is deleted, only when uninstalling. -->
<Custom Action="RemoveService" Before="RemoveFiles" Condition="REMOVE=&quot;ALL&quot;" />
<!-- Install and start the service after files are present, when not uninstalling. -->
<Custom Action="InitializeService" After="InstallFiles" Condition="NOT REMOVE=&quot;ALL&quot;" />
</InstallExecuteSequence>
</Package>
</Wix>
-2
View File
@@ -1,2 +0,0 @@
# Placeholder for application icons
# Windows icon (orchestrad.ico) should be placed here for embedding in Windows builds
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+38
View File
@@ -0,0 +1,38 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#5b5ef0"/>
<stop offset="1" stop-color="#7c3aed"/>
</linearGradient>
<radialGradient id="glow" cx="0.32" cy="0.26" r="0.9">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
<stop offset="0.55" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="3" stdDeviation="4" flood-color="#1e1b4b" flood-opacity="0.35"/>
</filter>
</defs>
<!-- Badge -->
<rect x="8" y="8" width="240" height="240" rx="52" fill="url(#bg)"/>
<rect x="8" y="8" width="240" height="240" rx="52" fill="url(#glow)"/>
<!-- Orchestration graph: a central hub conducting four directory nodes -->
<g stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.95">
<line x1="128" y1="128" x2="128" y2="66"/>
<line x1="128" y1="128" x2="190" y2="128"/>
<line x1="128" y1="128" x2="128" y2="190"/>
<line x1="128" y1="128" x2="66" y2="128"/>
</g>
<g filter="url(#soft)">
<!-- Satellite nodes -->
<circle cx="128" cy="60" r="20" fill="#ffffff"/>
<circle cx="196" cy="128" r="20" fill="#ffffff"/>
<circle cx="128" cy="196" r="20" fill="#ffffff"/>
<circle cx="60" cy="128" r="20" fill="#ffffff"/>
<!-- Central hub -->
<circle cx="128" cy="128" r="30" fill="#ffffff"/>
<circle cx="128" cy="128" r="14" fill="#6d40e8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+32 -55
View File
@@ -105,6 +105,32 @@ if (-not $All) {
# LDFlags for version injection
$LDFlags = "-s -w -X github.com/Grace-Solutions/OrchestrAD/internal/version.Version=$Version -X github.com/Grace-Solutions/OrchestrAD/internal/version.BuildTime=$BuildTime -X github.com/Grace-Solutions/OrchestrAD/internal/version.GitCommit=$GitCommit"
# Generate the Windows icon/version resource so the windows/* binaries embed the
# OrchestrAD icon. goversioninfo is a pure-Go tool and runs on any host; the
# arch-suffixed .syso files are only linked into their matching GOARCH build.
$BuildWindows = $All -or ($Platforms | Where-Object { $_.OS -eq "windows" })
if ($BuildWindows) {
Write-Host "`nGenerating Windows resource (icon + version metadata)..." -ForegroundColor Cyan
$GoVersionInfo = Join-Path (Join-Path $env:USERPROFILE "go\bin") "goversioninfo.exe"
if (-not (Test-Path $GoVersionInfo)) {
Write-Host " Installing goversioninfo..." -ForegroundColor Yellow
go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest
}
$vp = $Version -split '\.'
Push-Location $BackendDir
try {
foreach ($a in @(@{arch='amd64';flags=@('-64')}, @{arch='arm64';flags=@('-64','-arm')})) {
$out = "cmd\orchestrad\resource_windows_$($a.arch).syso"
& $GoVersionInfo @($a.flags) `
"-ver-major=$([int]$vp[0])" "-ver-minor=$([int]$vp[1])" "-ver-patch=$([int]$vp[2])" "-ver-build=$([int]$vp[3])" `
"-product-version=$([int]$vp[0]).$([int]$vp[1]).$([int]$vp[2])" `
"-o" $out "versioninfo.json"
}
} finally {
Pop-Location
}
}
Push-Location $BackendDir
try {
@@ -126,65 +152,16 @@ try {
Write-Host "`nBuilding for $OS/$Arch..." -ForegroundColor Green
# Pure-Go build (modernc.org/sqlite): CGO is disabled, so every target
# cross-compiles from any host with no C toolchain. Windows binaries pick
# up the icon/version resource generated into cmd/orchestrad before the
# loop (resource_windows_<arch>.syso).
$env:GOOS = $OS
$env:GOARCH = $Arch
$env:CGO_ENABLED = "1"
$PlatformLDFlags = $LDFlags
$SavedPath = $env:PATH
$env:CGO_ENABLED = "0"
# For cross-compilation, CGO needs special handling
if ($OS -ne "windows" -and $env:OS -eq "Windows_NT") {
$env:CGO_ENABLED = "0"
Write-Host " Note: CGO disabled for cross-compilation (SQLite will use pure Go driver)" -ForegroundColor Yellow
} elseif ($OS -eq "windows") {
# Produce a single-file binary with zero runtime DLL dependencies by
# statically linking the mingw-w64 C runtime. Auto-discover a gcc
# that targets $Arch from common install locations.
$GccName = if ($Arch -eq "arm64") { "aarch64-w64-mingw32-gcc.exe" } else { "gcc.exe" }
$env:CC = ""
if ($Arch -eq "arm64") {
$CcCandidates = @(
"C:\ProgramData\mingw64\aarch64-w64-mingw32\bin",
"C:\msys64\clangarm64\bin"
)
foreach ($Dir in $CcCandidates) {
if (Test-Path (Join-Path $Dir $GccName)) { $env:CC = (Join-Path $Dir $GccName); break }
}
if (-not $env:CC) {
Write-Host " Skipping windows/arm64: no aarch64-w64-mingw32-gcc on PATH (install an aarch64 mingw-w64 toolchain to enable)." -ForegroundColor Yellow
$env:PATH = $SavedPath
$env:CC = ""
continue
}
} else {
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
$GccCandidates = @(
"C:\ProgramData\mingw64\mingw64\bin",
"C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin",
"C:\msys64\ucrt64\bin",
"C:\msys64\mingw64\bin",
"C:\TDM-GCC-64\bin"
)
foreach ($GccDir in $GccCandidates) {
if (Test-Path (Join-Path $GccDir "gcc.exe")) {
$env:PATH = "$GccDir;$env:PATH"
Write-Host " Using gcc from $GccDir" -ForegroundColor DarkGray
break
}
}
}
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
throw "gcc is required on PATH for a Windows CGO build (mattn/go-sqlite3). Install mingw-w64 via 'choco install mingw' or point PATH at an existing gcc.exe."
}
}
$PlatformLDFlags = "$LDFlags -linkmode external -extldflags `"-static`""
}
go build -ldflags "$LDFlags" -o "$OutputFile" ./cmd/orchestrad
go build -ldflags "$PlatformLDFlags" -o "$OutputFile" ./cmd/orchestrad
$env:PATH = $SavedPath
$env:CC = ""
if ($LASTEXITCODE -eq 0) {
$FileSize = (Get-Item $OutputFile).Length / 1MB
Write-Host " Built: $OutputFile ($([math]::Round($FileSize, 2)) MB)" -ForegroundColor Green