diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f9e07e2 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 40586d1..90caa98 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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." diff --git a/.gitignore b/.gitignore index 9006577..4c93b73 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile index 9555873..53e48b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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} \ diff --git a/backend/cmd/orchestrad/main.go b/backend/cmd/orchestrad/main.go index d10f87c..f022f01 100644 --- a/backend/cmd/orchestrad/main.go +++ b/backend/cmd/orchestrad/main.go @@ -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 \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 ") + 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 [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 diff --git a/backend/go.mod b/backend/go.mod index c26ea24..e7ea6cd 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index 40a1f7a..59b5cc5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index 5e49718..d820a01 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -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 diff --git a/backend/internal/cli/service.go b/backend/internal/cli/service.go new file mode 100644 index 0000000..0d02fa1 --- /dev/null +++ b/backend/internal/cli/service.go @@ -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 +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 10bce2e..fc52635 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -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) } diff --git a/backend/internal/logging/logging.go b/backend/internal/logging/logging.go index 3d9fb92..d22b553 100644 --- a/backend/internal/logging/logging.go +++ b/backend/internal/logging/logging.go @@ -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) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index f7e0af5..81f4f69 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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. diff --git a/backend/versioninfo.json b/backend/versioninfo.json new file mode 100644 index 0000000..2fa3637 --- /dev/null +++ b/backend/versioninfo.json @@ -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": "" +} diff --git a/installer/OrchestrAD.wxs b/installer/OrchestrAD.wxs new file mode 100644 index 0000000..2c182de --- /dev/null +++ b/installer/OrchestrAD.wxs @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/icons/.gitkeep b/resources/icons/.gitkeep deleted file mode 100644 index d434679..0000000 --- a/resources/icons/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# Placeholder for application icons -# Windows icon (orchestrad.ico) should be placed here for embedding in Windows builds diff --git a/resources/icons/orchestrad.ico b/resources/icons/orchestrad.ico new file mode 100644 index 0000000..750d3bb Binary files /dev/null and b/resources/icons/orchestrad.ico differ diff --git a/resources/icons/orchestrad.png b/resources/icons/orchestrad.png new file mode 100644 index 0000000..399e1bd Binary files /dev/null and b/resources/icons/orchestrad.png differ diff --git a/resources/icons/orchestrad.svg b/resources/icons/orchestrad.svg new file mode 100644 index 0000000..5403910 --- /dev/null +++ b/resources/icons/orchestrad.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 6eb6566..2af431e 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -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_.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