diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..febfeed --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +bin +docs +examples +.gitignore +.git/ +**/*.md +Dockerfile +Makefile diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..51a9367 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..d9d049c --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,87 @@ +name: Check + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_call: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + format: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Format + run: go fmt ./... && git diff --exit-code + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Vet + run: go vet ./... + - name: Lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.58 + module: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Mod tidy + run: go mod tidy && git diff --exit-code + - name: Mod download + run: go mod download + - name: Mod verify + run: go mod verify + build: + needs: [format, lint, module] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Build + run: go build -o /dev/null + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Test + run: go test -v -race -shuffle=on -coverprofile=coverage.txt ./... + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.txt diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..6768382 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,66 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + check: + uses: ./.github/workflows/check.yaml + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Log in to the container registry + uses: docker/login-action@v5 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + create-release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + - name: Run goreleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ba077a4..e73981d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ bin +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..f0ff055 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,46 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy + # you may remove this if you don't need go generate + - go generate ./... + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + format: zip + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..33404a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.22 AS build-stage + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /zettelkasten-exporter + +FROM gcr.io/distroless/base-debian12 AS release-stage + +WORKDIR / + +COPY --from=build-stage /zettelkasten-exporter /zettelkasten-exporter + +USER nonroot:nonroot + +ENTRYPOINT ["/zettelkasten-exporter"] diff --git a/Makefile b/Makefile index d636f74..2aab284 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ BINARY_NAME=zettelkasten-exporter -.PHONY: all clean format test build run +.PHONY: all clean format vet lint test build run -all: format vet build +all: format vet build test clean: go clean @@ -14,11 +14,26 @@ format: vet: go vet ./... +lint: + golangci-lint run + test: go test ./... build: - go build -o bin/$(BINARY_NAME) ./cmd/zettelkasten-exporter/main.go + go build -o bin/$(BINARY_NAME) run: build - ZETTELKASTEN_DIRECTORY=./sample ./bin/$(BINARY_NAME) + LOG_LEVEL=INFO \ + ZETTELKASTEN_GIT_URL= \ + ZETTELKASTEN_GIT_BRANCH=master \ + ZETTELKASTEN_GIT_TOKEN= \ + COLLECTION_INTERVAL=10s \ + INFLUXDB_TOKEN= \ + INFLUXDB_URL=http://localhost:8086 \ + INFLUXDB_ORG=default \ + INFLUXDB_BUCKET=zettelkasten \ + ./bin/$(BINARY_NAME) + +docker: + docker build . -t zettelkasten-exporter:latest diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 78c6551..cdfc1ba 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -52,6 +52,11 @@ func (c *Collector) collectMetrics(root fs.FS) (metrics.Metrics, error) { notes := make(map[string]metrics.NoteMetrics) err := fs.WalkDir(root, ".", func(path string, dir fs.DirEntry, err error) error { + if err != nil { + slog.Error("Error on path. Will not enter it", slog.Any("error", err), slog.String("path", path)) + return nil + } + // Skip ignored files or directories if slices.Contains(c.config.IgnorePatterns, filepath.Base(path)) { if dir.IsDir() { diff --git a/internal/collector/note.go b/internal/collector/note.go index 7159caf..bebfd64 100644 --- a/internal/collector/note.go +++ b/internal/collector/note.go @@ -31,7 +31,7 @@ func collectLinks(content []byte) map[string]int { reader := text.NewReader(content) root := md.Parser().Parse(reader) links := make(map[string]int) - ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + err := ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if entering && slices.Contains(linkKinds, n.Kind()) { var target string switch v := n.(type) { @@ -55,6 +55,9 @@ func collectLinks(content []byte) map[string]int { } return ast.WalkContinue, nil }) + if err != nil { + slog.Error("Error walking note AST", slog.Any("error", err)) + } slog.Debug("Collected links", slog.Any("links", links)) return links } diff --git a/internal/config/config.go b/internal/config/config.go index 9a756b8..7550a46 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,7 +15,7 @@ import ( type Config struct { ZettelkastenDirectory string `koanf:"zettelkasten_directory" validate:"requiredWithout:ZettelkastenGitURL"` - ZettelkastenGitURL string `koanf:"zettelkasten_git_url" validate:"requiredWithout:ZettelkastenDirectory" validate:"url/isURL"` + ZettelkastenGitURL string `koanf:"zettelkasten_git_url" validate:"requiredWithout:ZettelkastenDirectory|url"` ZettelkastenGitBranch string `koanf:"zettelkasten_git_branch"` ZettelkastenGitToken string `koanf:"zettelkasten_git_token"` LogLevel slog.Level `koanf:"log_level"` @@ -32,16 +32,19 @@ func LoadConfig() (Config, error) { k := koanf.New(".") // Set default values - k.Load(structs.Provider(Config{ + err := k.Load(structs.Provider(Config{ LogLevel: slog.LevelInfo, IgnoreFiles: []string{".git", ".obsidian", ".trash", "README.md"}, ZettelkastenGitBranch: "main", CollectionInterval: time.Minute * 5, CollectHistoricalMetrics: true, }, "koanf"), nil) + if err != nil { + return Config{}, fmt.Errorf("error loading default config values: %w", err) + } // Load env variables - k.Load(env.ProviderWithValue("", ".", func(key, value string) (string, interface{}) { + err = k.Load(env.ProviderWithValue("", ".", func(key, value string) (string, interface{}) { key = strings.ToLower(key) if key == "collection_interval" { parsedValue, err := parseCollectionInterval(value) @@ -53,10 +56,16 @@ func LoadConfig() (Config, error) { } return key, value }), nil) + if err != nil { + return Config{}, fmt.Errorf("error loading env variables: %w", err) + } // Unmarshal into config struct var cfg Config - k.Unmarshal("", &cfg) + err = k.Unmarshal("", &cfg) + if err != nil { + return Config{}, fmt.Errorf("error unmarshalling config: %w", err) + } // Validate config v := validate.Struct(cfg) diff --git a/internal/zettelkasten/git.go b/internal/zettelkasten/git.go index 76912da..8c7b8dc 100644 --- a/internal/zettelkasten/git.go +++ b/internal/zettelkasten/git.go @@ -121,7 +121,7 @@ func (g GitZettelkasten) WalkHistory(walkFunc WalkFunc) error { return nil }) err = w.Reset(&git.ResetOptions{ - Commit: *&originalHash, + Commit: originalHash, Mode: git.HardReset, }) if err != nil { diff --git a/cmd/zettelkasten-exporter/main.go b/main.go similarity index 94% rename from cmd/zettelkasten-exporter/main.go rename to main.go index 990999a..f14fe1b 100644 --- a/cmd/zettelkasten-exporter/main.go +++ b/main.go @@ -14,7 +14,8 @@ import ( func main() { // Setup cfg, err := config.LoadConfig() - slog.SetLogLoggerLevel(cfg.LogLevel) + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: cfg.LogLevel})) + slog.SetDefault(logger) if err != nil { slog.Error("Error loading config", slog.Any("error", err)) os.Exit(1)