10 Commits

Author SHA1 Message Date
unkinben ae384e7b46 feat/multi-release (#11)
ci/woodpecker/tag/release Pipeline failed
Change to only tagging with the makefile.
Change the workflow to create the release based on the tag.
Build the binary for multiple os/archs

Reviewed-on: #11
2026-03-26 15:23:03 +11:00
unkinben e9ec29d60e fix: escape secrets (#10)
ci/woodpecker/release/release Pipeline failed
- must use $$ for escaped secrets

Reviewed-on: #10
2026-03-26 14:55:30 +11:00
unkinben 1daa48ade1 fix/release-pipeline-python (#9)
ci/woodpecker/release/release Pipeline failed
Reviewed-on: #9
2026-03-26 14:48:30 +11:00
unkinben b5978a18a1 fix/release-pipeline-python (#8)
ci/woodpecker/release/release Pipeline failed
Reviewed-on: #8
2026-03-26 13:46:37 +11:00
unkinben 6d7703c3f2 fix: use RELEASER_TOKEN for Gitea API auth instead of droneci password (#7)
ci/woodpecker/release/release Pipeline failed
droneci user lacks write access; switch to token-based auth header.

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.6 via Crush <crush@charm.land>

Reviewed-on: #7
2026-03-26 13:40:15 +11:00
unkinben 3291f8f73d fix: look up existing release by tag instead of creating a new one (#6)
ci/woodpecker/release/release Pipeline failed
tea creates the release before the pipeline runs; POST was failing with
conflict, leaving RELEASE_ID empty and skipping the asset upload.
Now GETs the release by tag, PATCHes its body, then uploads the binary.

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.6 via Crush <crush@charm.land>

Reviewed-on: #6
2026-03-26 13:17:25 +11:00
unkinben 3a4c9ea1c1 fix: surface release API errors in woodpecker pipeline (#5)
ci/woodpecker/release/release Pipeline failed
Capture and print the full Gitea API response before parsing the release
ID, and fail explicitly if the ID is empty so the root cause is visible
in CI logs instead of silently producing a malformed asset upload URL.

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.6 via Crush <crush@charm.land>

Reviewed-on: #5
2026-03-26 12:48:54 +11:00
unkinben b0d8f57b6f Add merged branch release notes to Gitea release body (#4)
ci/woodpecker/release/release Pipeline failed
Generates release notes from merged branches since last tag and
includes them in the release body via Gitea API.

Reviewed-on: #4
2026-03-25 21:27:45 +11:00
unkinben 45cb378022 Replace gitea-release plugin with curl-based release upload (#3)
Uses basic auth (droneci user) and Gitea API directly to create
the release and upload the binary asset.

Reviewed-on: #3
2026-03-25 19:45:34 +11:00
unkinben f65864af22 feat/version (#2)
Reviewed-on: #2
2026-03-25 19:25:12 +11:00
7 changed files with 150 additions and 27 deletions
+8
View File
@@ -0,0 +1,8 @@
when:
- event: pull_request
steps:
- name: lint
image: golangci/golangci-lint:latest
commands:
- golangci-lint run ./...
+8
View File
@@ -0,0 +1,8 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260325
commands:
- uvx pre-commit run --all-files
+59
View File
@@ -0,0 +1,59 @@
when:
- event: tag
steps:
- name: test
image: golang:latest
commands:
- go test ./...
- name: build-linux-amd64
image: golang:latest
commands:
- GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-linux-amd64 ./...
depends_on: [test]
- name: build-linux-arm64
image: golang:latest
commands:
- GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-linux-arm64 ./...
depends_on: [test]
- name: build-darwin-amd64
image: golang:latest
commands:
- GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-darwin-amd64 ./...
depends_on: [test]
- name: build-darwin-arm64
image: golang:latest
commands:
- GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" -o node-lookup-darwin-arm64 ./...
depends_on: [test]
- name: release
image: git.unkin.net/unkin/almalinux9-base:20260325
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- |
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
NOTES=$(git log "${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
else
NOTES=$(git log --pretty=format:"- %s")
fi
tea releases create "${CI_COMMIT_TAG}" --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "${NOTES}" --login gitea --repo "${CI_REPO}"
tea releases assets create "${CI_COMMIT_TAG}" \
node-lookup-linux-amd64 \
node-lookup-linux-arm64 \
node-lookup-darwin-amd64 \
node-lookup-darwin-arm64 \
--login gitea --repo "${CI_REPO}"
backend_options:
kubernetes:
serviceAccountName: default
depends_on: [build-linux-amd64, build-linux-arm64, build-darwin-amd64, build-darwin-arm64]
+8
View File
@@ -0,0 +1,8 @@
when:
- event: pull_request
steps:
- name: unit-tests
image: golang:latest
commands:
- go test -v -race ./...
+26 -2
View File
@@ -1,7 +1,8 @@
BINARY := node-lookup BINARY := node-lookup
GOFLAGS := -ldflags="-s -w" VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
.PHONY: all build test lint clean install .PHONY: all build test lint clean install patch minor major _tag
all: build all: build
@@ -19,3 +20,26 @@ clean:
install: install:
go install $(GOFLAGS) ./... go install $(GOFLAGS) ./...
# Bump helpers — reads the latest semver tag and creates the next one.
# If no tag exists yet, starts from v0.0.0.
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
_tag:
git push origin $(TAG)
+14 -4
View File
@@ -25,6 +25,8 @@ const (
appName = "node-lookup" appName = "node-lookup"
) )
var version = "dev"
// config holds all configurable values. Fields map 1:1 to config file keys, // config holds all configurable values. Fields map 1:1 to config file keys,
// env vars (NODE_LOOKUP_*), and (where applicable) CLI flags. // env vars (NODE_LOOKUP_*), and (where applicable) CLI flags.
type config struct { type config struct {
@@ -148,7 +150,7 @@ func queryPuppetDB(puppetDBURL, query string) ([]fact, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, fmt.Errorf("request failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
@@ -174,7 +176,7 @@ func valueString(raw json.RawMessage) string {
func valueAny(raw json.RawMessage) interface{} { func valueAny(raw json.RawMessage) interface{} {
var v interface{} var v interface{}
json.Unmarshal(raw, &v) _ = json.Unmarshal(raw, &v)
return v return v
} }
@@ -298,7 +300,7 @@ func run(cfg config, nodeName, factName, match, partialMatch string, showRole, n
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ") enc.SetIndent("", " ")
enc.SetEscapeHTML(false) enc.SetEscapeHTML(false)
enc.Encode(hostFactMap) _ = enc.Encode(hostFactMap)
case count: case count:
values := stdinLines values := stdinLines
@@ -317,7 +319,7 @@ func run(cfg config, nodeName, factName, match, partialMatch string, showRole, n
"all": map[string]interface{}{"hosts": hosts}, "all": map[string]interface{}{"hosts": hosts},
} }
b, _ := yaml.Marshal(inventory) b, _ := yaml.Marshal(inventory)
os.Stdout.Write(b) _, _ = os.Stdout.Write(b)
case nodeOnly: case nodeOnly:
for _, line := range returnData { for _, line := range returnData {
@@ -419,6 +421,14 @@ func main() {
configCmd.AddCommand(configInitCmd, configShowCmd) configCmd.AddCommand(configInitCmd, configShowCmd)
rootCmd.AddCommand(configCmd) rootCmd.AddCommand(configCmd)
versionCmd := &cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
}
rootCmd.AddCommand(versionCmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
os.Exit(1) os.Exit(1)
} }
+27 -21
View File
@@ -13,19 +13,11 @@ import (
// ---- helpers ---------------------------------------------------------------- // ---- helpers ----------------------------------------------------------------
func mustMarshal(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func newTestServer(t *testing.T, facts []fact) *httptest.Server { func newTestServer(t *testing.T, facts []fact) *httptest.Server {
t.Helper() t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(facts) _ = json.NewEncoder(w).Encode(facts)
})) }))
} }
@@ -184,7 +176,7 @@ func TestQueryPuppetDB_HTTPError(t *testing.T) {
func TestQueryPuppetDB_BadJSON(t *testing.T) { func TestQueryPuppetDB_BadJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("not json")) _, _ = w.Write([]byte("not json"))
})) }))
defer srv.Close() defer srv.Close()
@@ -244,8 +236,12 @@ func TestLoadConfig_FileOverride(t *testing.T) {
t.Setenv("NODE_LOOKUP_ROLE_FACT", "") t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
cfgDir := filepath.Join(dir, appName) cfgDir := filepath.Join(dir, appName)
os.MkdirAll(cfgDir, 0o755) if err := os.MkdirAll(cfgDir, 0o755); err != nil {
os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\nrole_fact: file_role\n"), 0o644) t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\nrole_fact: file_role\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
@@ -266,8 +262,12 @@ func TestLoadConfig_EnvOverridesFile(t *testing.T) {
t.Setenv("NODE_LOOKUP_ROLE_FACT", "") t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
cfgDir := filepath.Join(dir, appName) cfgDir := filepath.Join(dir, appName)
os.MkdirAll(cfgDir, 0o755) if err := os.MkdirAll(cfgDir, 0o755); err != nil {
os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\n"), 0o644) t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte("puppetdb_url: http://file:8080/facts\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
@@ -285,8 +285,12 @@ func TestLoadConfig_InvalidYAML(t *testing.T) {
t.Setenv("NODE_LOOKUP_ROLE_FACT", "") t.Setenv("NODE_LOOKUP_ROLE_FACT", "")
cfgDir := filepath.Join(dir, appName) cfgDir := filepath.Join(dir, appName)
os.MkdirAll(cfgDir, 0o755) if err := os.MkdirAll(cfgDir, 0o755); err != nil {
os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(":\tinvalid: yaml:\n"), 0o644) t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(":\tinvalid: yaml:\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := loadConfig() _, err := loadConfig()
if err == nil { if err == nil {
@@ -316,7 +320,9 @@ func TestWriteDefaultConfig_AlreadyExists(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("XDG_CONFIG_HOME", dir)
writeDefaultConfig() if err := writeDefaultConfig(); err != nil {
t.Fatal(err)
}
err := writeDefaultConfig() err := writeDefaultConfig()
if err == nil { if err == nil {
t.Fatal("expected error when config already exists") t.Fatal("expected error when config already exists")
@@ -348,11 +354,11 @@ func TestAllFactsForNode_QueryContainsCertname(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedQuery = r.URL.Query().Get("query") receivedQuery = r.URL.Query().Get("query")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]fact{}) _ = json.NewEncoder(w).Encode([]fact{})
})) }))
defer srv.Close() defer srv.Close()
allFactsForNode(srv.URL+"/pdb/query/v4/facts", "mynode.example.com") _, _ = allFactsForNode(srv.URL+"/pdb/query/v4/facts", "mynode.example.com")
if !strings.Contains(receivedQuery, "mynode.example.com") { if !strings.Contains(receivedQuery, "mynode.example.com") {
t.Fatalf("expected certname in query, got: %s", receivedQuery) t.Fatalf("expected certname in query, got: %s", receivedQuery)
} }
@@ -399,10 +405,10 @@ func TestRun_AllFacts_PrintsSortedByName(t *testing.T) {
cfg := config{PuppetDBURL: srv.URL + "/pdb/query/v4/facts", RoleFact: "enc_role"} cfg := config{PuppetDBURL: srv.URL + "/pdb/query/v4/facts", RoleFact: "enc_role"}
err := run(cfg, "node1", "", "", "", false, false, false, false, false, false, true) err := run(cfg, "node1", "", "", "", false, false, false, false, false, false, true)
w.Close() _ = w.Close()
os.Stdout = old os.Stdout = old
var buf strings.Builder var buf strings.Builder
io.Copy(&buf, r) _, _ = io.Copy(&buf, r)
out := buf.String() out := buf.String()
if err != nil { if err != nil {