Skip to content
Merged
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
73 changes: 0 additions & 73 deletions .circleci/config.yml

This file was deleted.

87 changes: 87 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: CI

on:
push:
branches:
- "*"
tags:
- "*"
pull_request:
release:
types: [published]

jobs:
test:
runs-on: ubuntu-latest
env:
MINIO_ENDPOINT: localhost:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
steps:
- uses: actions/checkout@v4

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

# Pull after Hub login so anonymous rate limits do not block CI.
# alpine/minio pins the Oct 2025 release (CVE-2025-62506); official
# minio/minio images stopped publishing after 2025-09.
# Use /tmp/data: the image runs non-root and cannot write the default /data VOLUME.
- name: Start MinIO
run: |
docker run -d --name minio \
-p 9000:9000 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
alpine/minio:RELEASE.2025-10-15T17-29-55Z \
server /tmp/data
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:9000/minio/health/live >/dev/null; then
echo "MinIO is ready"
exit 0
fi
sleep 1
done
echo "MinIO failed to become ready" >&2
docker logs minio
exit 1

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Run tests
run: |
go test -cover -v -coverprofile=coverage.out -covermode=atomic ./... ./apps/...
go tool cover -func=coverage.out | tail -1
go tool cover -html=coverage.out -o=cover.html

- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: |
coverage.out
cover.html

build:
needs: test
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push Docker image
run: make docker
27 changes: 0 additions & 27 deletions .github/workflows/docker-image.yml

This file was deleted.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# task-tools
[![CircleCI](https://dl.circleci.com/status-badge/img/gh/pcelvng/task-tools/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/pcelvng/task-tools/tree/main)
[![CI](https://github.com/pcelvng/task-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/pcelvng/task-tools/actions/workflows/ci.yml)

A set of tools and apps used in the [task](https://github.com/pcelvng/task) ecosystem

Expand Down
2 changes: 1 addition & 1 deletion apps/flowlord/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Built-in web UI for monitoring workflows and troubleshooting. Uses Go templates
- Workflow phase visualization
- System statistics

Access at `http://localhost:8080/` (or configured port)
Access at `http://localhost:8080/` by default (`status_port = 8080`). Set `status_port = 0` to run headless with orchestration only — the dashboard, backload API, and other HTTP endpoints are disabled.

| Files View | Tasks View | Alerts View | Workflow View |
|:----------:|:----------:|:-----------:|:-------------:|
Expand Down
3 changes: 2 additions & 1 deletion apps/flowlord/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type options struct {
DoneTopic string `toml:"done_topic" comment:"default is done"`
FileTopic string `toml:"file_topic" comment:"file topic for file watching"`
FailedTopic string `toml:"failed_topic" comment:"all retry failures published to this topic default is retry-failed, disable with '-'"`
Port int `toml:"status_port"`
Port int `toml:"status_port" comment:"HTTP port for dashboard and ops API (default 8080; 0 disables)"`
Host string `toml:"host" comment:"host address of server "`
Slack *Notification `toml:"slack"`
Bus bus.Options `toml:"bus"`
Expand All @@ -54,6 +54,7 @@ func main() {
Refresh: time.Minute * 15,
DoneTopic: "done",
Host: "localhost",
Port: 8080,
FailedTopic: "retry-failed",
File: file.NewOptions(),
Slack: &Notification{},
Expand Down
41 changes: 33 additions & 8 deletions apps/flowlord/taskmaster.go
Original file line number Diff line number Diff line change
Expand Up @@ -593,23 +593,45 @@ func (tm *taskMaster) handleNotifications(taskChan chan task.Task, ctx context.C
case <-ticker.C:
tm.notify.Tick(tm.taskCache, tm.sendAlertSummary)
case tsk := <-taskChan:
// if the task result is an alert result, send a slack notification now
// Always persist to alert_records (dashboard + batch summary)
if err := tm.taskCache.AddAlert(tsk, tsk.Msg); err != nil {
log.Printf("failed to store alert: %v", err)
}
// AlertResult also gets an immediate Slack notification
if tsk.Result == task.AlertResult {
b, _ := json.MarshalIndent(tsk, "", " ")
if err := tm.notify.Slack.Notify(string(b), slack.Critical); err != nil {
if err := tm.notify.Slack.Notify(tm.formatImmediateAlert(tsk), slack.Critical); err != nil {
log.Println(err)
}
} else { // if the task result is not an alert result add to the tasks list summary
if err := tm.taskCache.AddAlert(tsk, tsk.Msg); err != nil {
log.Printf("failed to store alert: %v", err)
}
}
case <-ctx.Done():
return
}
}
}

// formatImmediateAlert builds a Slack message for a single AlertResult task.
// Matches the batch summary header plus one "task:job | message" line.
func (tm *taskMaster) formatImmediateAlert(tsk task.Task) string {
var message strings.Builder
if tm.port != 0 {
message.WriteString(fmt.Sprintf("see report at %v:%d/web/alert?date=%s\n",
tm.HostName, tm.port, time.Now().Format("2006-01-02")))
}

key := tsk.Type
job := tsk.Job
if job == "" {
if meta, err := url.ParseQuery(tsk.Meta); err == nil {
job = meta.Get("job")
}
}
if job != "" {
key += ":" + job
}
message.WriteString(fmt.Sprintf("%s | %s\n", key, tsk.Msg))
return message.String()
}

// sendAlertSummary sends a formatted alert summary to Slack
// This can be reused by backup alert system and other components
func (tm *taskMaster) sendAlertSummary(alerts []sqlite.AlertRecord) error {
Expand All @@ -622,7 +644,10 @@ func (tm *taskMaster) sendAlertSummary(alerts []sqlite.AlertRecord) error {

// format message similar to current Slack format
var message strings.Builder
message.WriteString(fmt.Sprintf("see report at %v:%d/web/alert?date=%s\n", tm.HostName, tm.port, time.Now().Format("2006-01-02")))
if tm.port != 0 {
message.WriteString(fmt.Sprintf("see report at %v:%d/web/alert?date=%s\n",
tm.HostName, tm.port, time.Now().Format("2006-01-02")))
}

for _, line := range summary {
message.WriteString(fmt.Sprintf("%-35s%5d %s\n",
Expand Down
25 changes: 25 additions & 0 deletions apps/flowlord/taskmaster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
Expand Down Expand Up @@ -630,3 +631,27 @@ func TestNotification_Tick(t *testing.T) {

trial.New(fn, cases).SubTest(t)
}

func TestFormatImmediateAlert(t *testing.T) {
tsk := task.Task{Type: "fetch", Job: "daily", Msg: "timeout"}
today := time.Now().Format("2006-01-02")

type in struct {
port int
}
fn := func(in in) (string, error) {
tm := &taskMaster{HostName: "flowlord", port: in.port}
return tm.formatImmediateAlert(tsk), nil
}
cases := trial.Cases[in, string]{
"dashboard enabled": {
Input: in{port: 8080},
Expected: fmt.Sprintf("see report at flowlord:8080/web/alert?date=%s\nfetch:daily | timeout\n", today),
},
"dashboard disabled": {
Input: in{port: 0},
Expected: "fetch:daily | timeout\n",
},
}
trial.New(fn, cases).SubTest(t)
}
Loading
Loading