Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ jobs:
with:
go-version-file: go.mod
- run: go build -o gortex ./cmd/gortex/
# The unused-method check from build-linux-static, linked the way
# release.yml links these platforms' binaries: no build tags, and on
# windows the mingw runtime linked in statically. Compilation is cached
# from the build above, so this costs one more link.
- name: Keep unused methods prunable
shell: bash
run: |
set -o pipefail
extld=
if [ "$RUNNER_OS" = Windows ]; then extld=-extldflags=-static; fi
go build -ldflags "-s -w $extld -dumpdep" \
-o "$RUNNER_TEMP/gortex-dumpdep" ./cmd/gortex/ 2>&1 | go run ./scripts/ci-dumpdep

smoke:
needs: plan
Expand Down Expand Up @@ -183,6 +195,22 @@ jobs:
file gortex-static
sh scripts/verify-static-elf.sh gortex-static

# Executing a text/template or html/template, like any other reflect
# method lookup by a run-time name, makes the linker keep every exported
# method of every reachable type: ~6 MB of the stripped binary when the
# service unit files were rendered with text/template. Relink (the
# compile above is cached) with -dumpdep, which lists what the linker
# keeps and why; scripts/ci-dumpdep fails with the call chain that
# reaches such a lookup.
- name: Keep unused methods prunable
env:
CGO_ENABLED: "1"
run: |
set -o pipefail
go build -tags netgo,osusergo,static_link \
-ldflags '-s -w -extldflags "-static" -dumpdep' \
-o /dev/null ./cmd/gortex/ 2>&1 | go run ./scripts/ci-dumpdep

# The static-only code path (custom grammars declining instead of
# dlopen'ing) is compiled out of every other job, so it needs its tests
# run under the tag or they never execute anywhere.
Expand Down
97 changes: 36 additions & 61 deletions cmd/gortex/daemon_service.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"bytes"
"encoding/xml"
"errors"
"fmt"
Expand All @@ -12,7 +11,6 @@ import (
"runtime"
"slices"
"strings"
"text/template"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -255,15 +253,18 @@ func runDaemonServiceStatus(cmd *cobra.Command, _ []string) error {
// EnvironmentVariables carries the installing shell's PATH with missing
// Homebrew / system defaults appended, plus any XDG_* overrides in effect at
// install time, and GORTEX_DAEMON_LOGFILE — see servicePath and xdgServiceEnv.
//
// The verbs take, in order: label, executable, log path (twice), PATH, and
// the rendered <key>/<string> lines of the other variables.
const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{.Label}}</string>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>{{.Exe}}</string>
<string>%s</string>
<string>daemon</string>
<string>start</string>
</array>
Expand All @@ -275,17 +276,13 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<false/>
</dict>
<key>StandardOutPath</key>
<string>{{.LogPath}}</string>
<string>%s</string>
<key>StandardErrorPath</key>
<string>{{.LogPath}}</string>
<string>%s</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{{.Path}}</string>
{{- range .EnvVars}}
<key>{{.Key}}</key>
<string>{{.Value}}</string>
{{- end}}
<string>%s</string>%s
</dict>
</dict>
</plist>
Expand All @@ -294,25 +291,18 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
// renderLaunchdPlist fills launchdPlistTemplate. String values are
// XML-escaped so a path containing an XML metacharacter can't produce a
// malformed, unloadable plist.
func renderLaunchdPlist(label, exe, logPath string, env []serviceEnvVar) (string, error) {
data := struct {
Label, Exe, LogPath, Path string
EnvVars []serviceEnvVar
}{
Label: xmlEscape(label),
Exe: xmlEscape(exe),
LogPath: xmlEscape(logPath),
Path: xmlEscape(servicePath([]string{"/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"})),
EnvVars: make([]serviceEnvVar, len(env)),
}
for i, e := range env {
data.EnvVars[i] = serviceEnvVar{Key: e.Key, Value: xmlEscape(e.Value)}
}
var buf bytes.Buffer
if err := template.Must(template.New("plist").Parse(launchdPlistTemplate)).Execute(&buf, data); err != nil {
return "", err
//
// Unit files are rendered with fmt, not text/template: executing a template
// looks fields up by reflection, which makes the linker keep every exported
// method of every reachable type (see scripts/ci-dumpdep).
func renderLaunchdPlist(label, exe, logPath string, env []serviceEnvVar) string {
var vars strings.Builder
for _, e := range env {
fmt.Fprintf(&vars, "\n <key>%s</key>\n <string>%s</string>", e.Key, xmlEscape(e.Value))
}
return buf.String(), nil
logPath = xmlEscape(logPath)
path := servicePath([]string{"/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"})
return fmt.Sprintf(launchdPlistTemplate, xmlEscape(label), xmlEscape(exe), logPath, logPath, xmlEscape(path), vars.String())
}

func launchdPlistPath() (string, error) {
Expand All @@ -337,10 +327,7 @@ func installLaunchd(w io.Writer, exe string) error {
return err
}
stderrLog := daemon.StderrLogPath(logFile)
plist, err := renderLaunchdPlist(daemonServiceName, exe, stderrLog, xdgServiceEnv(logFile))
if err != nil {
return fmt.Errorf("render plist: %w", err)
}
plist := renderLaunchdPlist(daemonServiceName, exe, stderrLog, xdgServiceEnv(logFile))
if err := os.WriteFile(path, []byte(plist), 0o644); err != nil {
return fmt.Errorf("write plist: %w", err)
}
Expand Down Expand Up @@ -625,50 +612,41 @@ func noteRunningDaemon(w io.Writer, daemonRunning func() bool) {
// PATH, XDG_* overrides, and GORTEX_DAEMON_LOGFILE at install time so the
// daemon resolves the same paths as the installing shell — see servicePath
// and xdgServiceEnv.
//
// The verbs take, in order: executable, the rendered Environment= lines, and
// the log path (twice).
const systemdUnitTemplate = `[Unit]
Description=Gortex code intelligence daemon
Documentation=https://github.com/zzet/gortex
After=network.target

[Service]
Type=simple
ExecStart={{.Exe}} daemon start
{{- range .EnvVars}}
Environment={{.}}
{{- end}}
ExecStart=%s daemon start%s
Restart=on-failure
RestartSec=2
StandardOutput=append:{{.LogPath}}
StandardError=append:{{.LogPath}}
StandardOutput=append:%s
StandardError=append:%s

[Install]
WantedBy=default.target
`

// renderSystemdUnit fills systemdUnitTemplate, quoting Environment=
// assignments that need it.
func renderSystemdUnit(exe, logPath string, env []serviceEnvVar) (string, error) {
func renderSystemdUnit(exe, logPath string, env []serviceEnvVar) string {
if path := servicePath(nil); path != "" {
env = append([]serviceEnvVar{{Key: "PATH", Value: path}}, env...)
}
data := struct {
Exe, LogPath string
EnvVars []string
}{
Exe: exe,
// systemd expands % specifiers in StandardOutput= and StandardError=
// paths too, so escape them as systemdEnvValue does.
LogPath: strings.ReplaceAll(logPath, "%", "%%"),
EnvVars: make([]string, len(env)),
}
for i, e := range env {
data.EnvVars[i] = systemdEnvValue(e.Key + "=" + e.Value)
}
var buf bytes.Buffer
if err := template.Must(template.New("unit").Parse(systemdUnitTemplate)).Execute(&buf, data); err != nil {
return "", err
var vars strings.Builder
for _, e := range env {
vars.WriteString("\nEnvironment=")
vars.WriteString(systemdEnvValue(e.Key + "=" + e.Value))
}
return buf.String(), nil
// systemd expands % specifiers in StandardOutput= and StandardError=
// paths too, so escape them as systemdEnvValue does.
logPath = strings.ReplaceAll(logPath, "%", "%%")
return fmt.Sprintf(systemdUnitTemplate, exe, vars.String(), logPath, logPath)
}

func systemdUnitPath() (string, error) {
Expand All @@ -693,10 +671,7 @@ func installSystemd(w io.Writer, exe string) error {
return err
}
stderrLog := daemon.StderrLogPath(logFile)
unit, err := renderSystemdUnit(exe, stderrLog, xdgServiceEnv(logFile))
if err != nil {
return fmt.Errorf("render unit: %w", err)
}
unit := renderSystemdUnit(exe, stderrLog, xdgServiceEnv(logFile))
if err := os.WriteFile(path, []byte(unit), 0o644); err != nil {
return fmt.Errorf("write unit: %w", err)
}
Expand Down
129 changes: 129 additions & 0 deletions cmd/gortex/daemon_service_render_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package main

import (
"os"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

// These pin whole unit files, byte for byte, so a change to how they are
// rendered cannot move a newline, an indent or an escape unnoticed. The
// render tests in daemon_service_test.go check how the installing shell's
// PATH and daemon log override reach them.

// launchdDefaultPATH is the plist PATH when the installing shell's PATH
// contributes nothing: the launchd defaults, joined with the host's list
// separator.
func launchdDefaultPATH() string {
return strings.Join([]string{"/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"}, string(os.PathListSeparator))
}

func TestRenderLaunchdPlist_Golden(t *testing.T) {
const head = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.zzet.gortex</string>
<key>ProgramArguments</key>
<array>
<string>/opt/a&amp;b/gortex</string>
<string>daemon</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>/var/o&#39;neil/&lt;logs&gt;/daemon.stderr.log</string>
<key>StandardErrorPath</key>
<string>/var/o&#39;neil/&lt;logs&gt;/daemon.stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>`
const tail = `
</dict>
</dict>
</plist>
`
for _, tc := range []struct {
name string
env []serviceEnvVar
vars string
}{
{name: "no env"},
{
name: "env",
env: []serviceEnvVar{
{Key: "XDG_CONFIG_HOME", Value: `/srv/a&b/"config"`},
{Key: "GORTEX_DAEMON_LOGFILE", Value: "/srv/a&b/daemon.log"},
},
vars: `
<key>XDG_CONFIG_HOME</key>
<string>/srv/a&amp;b/&#34;config&#34;</string>
<key>GORTEX_DAEMON_LOGFILE</key>
<string>/srv/a&amp;b/daemon.log</string>`,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("PATH", "")
out := renderLaunchdPlist("com.zzet.gortex", "/opt/a&b/gortex", "/var/o'neil/<logs>/daemon.stderr.log", tc.env)
require.Equal(t, head+launchdDefaultPATH()+"</string>"+tc.vars+tail, out)
assertWellFormedXML(t, out)
})
}
}

func TestRenderSystemdUnit_Golden(t *testing.T) {
const head = `[Unit]
Description=Gortex code intelligence daemon
Documentation=https://github.com/zzet/gortex
After=network.target

[Service]
Type=simple
ExecStart=/srv/u/.local/bin/gortex daemon start`
const tail = `
Restart=on-failure
RestartSec=2
StandardOutput=append:/srv/u/100%% logs/daemon.stderr.log
StandardError=append:/srv/u/100%% logs/daemon.stderr.log

[Install]
WantedBy=default.target
`
for _, tc := range []struct {
name string
env []serviceEnvVar
vars string
}{
{name: "no env"},
{
name: "env",
env: []serviceEnvVar{
{Key: "XDG_CACHE_HOME", Value: "/srv/u/.cache"},
{Key: "XDG_DATA_HOME", Value: "/srv/u/has space"},
{Key: "XDG_CONFIG_HOME", Value: `/srv/u/"q"\x`},
{Key: "GORTEX_DAEMON_LOGFILE", Value: "/srv/u/100% logs/daemon.log"},
},
vars: `
Environment=XDG_CACHE_HOME=/srv/u/.cache
Environment="XDG_DATA_HOME=/srv/u/has space"
Environment="XDG_CONFIG_HOME=/srv/u/\"q\"\\x"
Environment="GORTEX_DAEMON_LOGFILE=/srv/u/100%% logs/daemon.log"`,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("PATH", "")
out := renderSystemdUnit("/srv/u/.local/bin/gortex", "/srv/u/100% logs/daemon.stderr.log", tc.env)
require.Equal(t, head+tc.vars+tail, out)
})
}
}
Loading
Loading