mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +01:00
feat: adding background jobs, Dockerfile
This commit is contained in:
@@ -69,6 +69,8 @@ func NewRouter(db *gorm.DB) http.Handler {
|
||||
// Also serving a versioned JWKS for swagger, which uses BasePath
|
||||
v1.Get("/.well-known/jwks.json", handlers.JWKSHandler)
|
||||
|
||||
v1.Get("/healthz", handlers.HealthCheck)
|
||||
|
||||
v1.Route("/auth", func(a chi.Router) {
|
||||
a.Post("/{provider}/start", handlers.AuthStart(db))
|
||||
a.Get("/{provider}/callback", handlers.AuthCallback(db))
|
||||
|
||||
@@ -22,6 +22,7 @@ func NewRuntime() *Runtime {
|
||||
d := db.Open(cfg.DbURL)
|
||||
|
||||
err = db.Run(d,
|
||||
&models.Job{},
|
||||
&models.MasterKey{},
|
||||
&models.SigningKey{},
|
||||
&models.User{},
|
||||
|
||||
53
internal/bg/archer_cleanup.go
Normal file
53
internal/bg/archer_cleanup.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/dyaksa/archer"
|
||||
"github.com/dyaksa/archer/job"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CleanupArgs struct {
|
||||
RetainDays int `json:"retain_days"`
|
||||
Table string `json:"table"`
|
||||
}
|
||||
|
||||
type JobRow struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
Status string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (JobRow) TableName() string { return "jobs" }
|
||||
|
||||
func CleanupWorker(gdb *gorm.DB, jobs *Jobs, retainDays int) archer.WorkerFn {
|
||||
return func(ctx context.Context, j job.Job) (any, error) {
|
||||
if err := CleanupJobs(gdb, retainDays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// schedule tomorrow 03:30
|
||||
next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 30*time.Minute)
|
||||
|
||||
_, _ = jobs.Enqueue(
|
||||
ctx,
|
||||
uuid.NewString(),
|
||||
"archer_cleanup",
|
||||
CleanupArgs{RetainDays: retainDays, Table: "jobs"},
|
||||
archer.WithScheduleTime(next),
|
||||
archer.WithMaxRetries(1),
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func CleanupJobs(db *gorm.DB, retainDays int) error {
|
||||
cutoff := time.Now().AddDate(0, 0, -retainDays)
|
||||
return db.
|
||||
Where("status IN ?", []string{"success", "failed", "cancelled"}).
|
||||
Where("updated_at < ?", cutoff).
|
||||
Delete(&JobRow{}).Error
|
||||
}
|
||||
274
internal/bg/bastion.go
Normal file
274
internal/bg/bastion.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dyaksa/archer"
|
||||
"github.com/dyaksa/archer/job"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ----- Public types -----
|
||||
|
||||
type BastionBootstrapArgs struct{}
|
||||
|
||||
type BastionBootstrapFailure struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Step string `json:"step"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type BastionBootstrapResult struct {
|
||||
Status string `json:"status"`
|
||||
Processed int `json:"processed"`
|
||||
Ready int `json:"ready"`
|
||||
Failed int `json:"failed"`
|
||||
ElapsedMs int `json:"elapsed_ms"`
|
||||
FailedServer []uuid.UUID `json:"failed_server_ids"`
|
||||
Failures []BastionBootstrapFailure `json:"failures"`
|
||||
}
|
||||
|
||||
// ----- Worker -----
|
||||
|
||||
func BastionBootstrapWorker(db *gorm.DB) archer.WorkerFn {
|
||||
return func(ctx context.Context, j job.Job) (any, error) {
|
||||
jobID := j.ID
|
||||
start := time.Now()
|
||||
|
||||
var servers []models.Server
|
||||
if err := db.
|
||||
Preload("SshKey").
|
||||
Where("role = ? AND status = ?", "bastion", "pending").
|
||||
Find(&servers).Error; err != nil {
|
||||
log.Printf("[bastion] level=ERROR job=%s step=query msg=%q", jobID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[bastion] level=INFO job=%s step=start count=%d", jobID, len(servers))
|
||||
|
||||
proc, ok, fail := 0, 0, 0
|
||||
var failedIDs []uuid.UUID
|
||||
var failures []BastionBootstrapFailure
|
||||
|
||||
perHostTimeout := 8 * time.Minute
|
||||
|
||||
for i := range servers {
|
||||
s := &servers[i]
|
||||
hostStart := time.Now()
|
||||
proc++
|
||||
|
||||
// 1) Defensive IP check
|
||||
if s.PublicIPAddress == nil || *s.PublicIPAddress == "" {
|
||||
fail++
|
||||
failedIDs = append(failedIDs, s.ID)
|
||||
failures = append(failures, BastionBootstrapFailure{ID: s.ID, Step: "ip_check", Reason: "missing public ip"})
|
||||
logHostErr(jobID, s, "ip_check", fmt.Errorf("missing public ip"))
|
||||
_ = setServerStatus(db, s.ID, "failed")
|
||||
continue
|
||||
}
|
||||
|
||||
// 2) Move to provisioning
|
||||
if err := setServerStatus(db, s.ID, "provisioning"); err != nil {
|
||||
fail++
|
||||
failedIDs = append(failedIDs, s.ID)
|
||||
failures = append(failures, BastionBootstrapFailure{ID: s.ID, Step: "set_provisioning", Reason: err.Error()})
|
||||
logHostErr(jobID, s, "set_provisioning", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 3) Decrypt private key for org
|
||||
privKey, err := utils.DecryptForOrg(
|
||||
s.OrganizationID,
|
||||
s.SshKey.EncryptedPrivateKey,
|
||||
s.SshKey.PrivateIV,
|
||||
s.SshKey.PrivateTag,
|
||||
db,
|
||||
)
|
||||
if err != nil {
|
||||
fail++
|
||||
failedIDs = append(failedIDs, s.ID)
|
||||
failures = append(failures, BastionBootstrapFailure{ID: s.ID, Step: "decrypt_key", Reason: err.Error()})
|
||||
logHostErr(jobID, s, "decrypt_key", err)
|
||||
_ = setServerStatus(db, s.ID, "failed")
|
||||
continue
|
||||
}
|
||||
|
||||
// 4) SSH + install docker
|
||||
host := net.JoinHostPort(*s.PublicIPAddress, "22")
|
||||
runCtx, cancel := context.WithTimeout(ctx, perHostTimeout)
|
||||
out, err := sshInstallDockerWithOutput(runCtx, host, s.SSHUser, []byte(privKey))
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
fail++
|
||||
failedIDs = append(failedIDs, s.ID)
|
||||
failures = append(failures, BastionBootstrapFailure{ID: s.ID, Step: "ssh_install", Reason: err.Error()})
|
||||
// include a short tail of output to speed debugging without flooding logs
|
||||
tail := out
|
||||
if len(tail) > 800 {
|
||||
tail = tail[len(tail)-800:]
|
||||
}
|
||||
logHostErr(jobID, s, "ssh_install", fmt.Errorf("%v | tail=%q", err, tail))
|
||||
_ = setServerStatus(db, s.ID, "failed")
|
||||
continue
|
||||
}
|
||||
|
||||
// 5) Mark ready
|
||||
if err := setServerStatus(db, s.ID, "ready"); err != nil {
|
||||
fail++
|
||||
failedIDs = append(failedIDs, s.ID)
|
||||
failures = append(failures, BastionBootstrapFailure{ID: s.ID, Step: "set_ready", Reason: err.Error()})
|
||||
logHostErr(jobID, s, "set_ready", err)
|
||||
_ = setServerStatus(db, s.ID, "failed")
|
||||
continue
|
||||
}
|
||||
|
||||
ok++
|
||||
logHostInfo(jobID, s, "done", "host completed",
|
||||
"elapsed_ms", time.Since(hostStart).Milliseconds())
|
||||
}
|
||||
|
||||
res := BastionBootstrapResult{
|
||||
Status: "ok",
|
||||
Processed: proc,
|
||||
Ready: ok,
|
||||
Failed: fail,
|
||||
ElapsedMs: int(time.Since(start).Milliseconds()),
|
||||
FailedServer: failedIDs,
|
||||
Failures: failures,
|
||||
}
|
||||
|
||||
log.Printf("[bastion] level=INFO job=%s step=finish processed=%d ready=%d failed=%d elapsed_ms=%d",
|
||||
jobID, proc, ok, fail, res.ElapsedMs)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Helpers -----
|
||||
|
||||
func setServerStatus(db *gorm.DB, id uuid.UUID, status string) error {
|
||||
return db.Model(&models.Server{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// uniform log helpers for consistent, greppable output
|
||||
func logHostErr(jobID string, s *models.Server, step string, err error) {
|
||||
ip := ""
|
||||
if s.PublicIPAddress != nil {
|
||||
ip = *s.PublicIPAddress
|
||||
}
|
||||
log.Printf("[bastion] level=ERROR job=%s server_id=%s host=%s step=%s msg=%q",
|
||||
jobID, s.ID, ip, step, err)
|
||||
}
|
||||
|
||||
func logHostInfo(jobID string, s *models.Server, step, msg string, kv ...any) {
|
||||
ip := ""
|
||||
if s.PublicIPAddress != nil {
|
||||
ip = *s.PublicIPAddress
|
||||
}
|
||||
log.Printf("[bastion] level=INFO job=%s server_id=%s host=%s step=%s %s kv=%v",
|
||||
jobID, s.ID, ip, step, msg, kv)
|
||||
}
|
||||
|
||||
// ----- SSH & command execution -----
|
||||
|
||||
// returns combined stdout/stderr so caller can log it on error
|
||||
// returns combined stdout/stderr so caller can log it on error
|
||||
func sshInstallDockerWithOutput(ctx context.Context, host, user string, privateKeyPEM []byte) (string, error) {
|
||||
signer, err := ssh.ParsePrivateKey(privateKeyPEM)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: known_hosts verification
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// context-aware dial
|
||||
dialer := &net.Dialer{}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", host)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c, chans, reqs, err := ssh.NewClientConn(conn, host, config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ssh handshake: %w", err)
|
||||
}
|
||||
client := ssh.NewClient(c, chans, reqs)
|
||||
defer client.Close()
|
||||
|
||||
sess, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session: %w", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
// --- script to run remotely (no extra quoting) ---
|
||||
script := `
|
||||
set -euxo pipefail
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
fi
|
||||
|
||||
# try to enable/start (handles distros with systemd)
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl enable --now docker || true
|
||||
fi
|
||||
|
||||
# add current ssh user to docker group if exists
|
||||
if getent group docker >/dev/null 2>&1; then
|
||||
sudo usermod -aG docker "$(id -un)" || true
|
||||
fi
|
||||
`
|
||||
|
||||
// Send script via stdin to avoid quoting/escaping issues
|
||||
sess.Stdin = strings.NewReader(script)
|
||||
|
||||
// Capture combined stdout+stderr
|
||||
out, runErr := sess.CombinedOutput("bash -s")
|
||||
return string(out), wrapSSHError(runErr, string(out))
|
||||
}
|
||||
|
||||
// annotate common SSH/remote failure modes to speed triage
|
||||
func wrapSSHError(err error, output string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(output, "Could not resolve host"):
|
||||
return fmt.Errorf("remote run: name resolution failed: %w", err)
|
||||
case strings.Contains(output, "Permission denied"):
|
||||
return fmt.Errorf("remote run: permission denied (check user/key/authorized_keys): %w", err)
|
||||
case strings.Contains(output, "apt-get"):
|
||||
return fmt.Errorf("remote run: apt failed: %w", err)
|
||||
case strings.Contains(output, "yum"):
|
||||
return fmt.Errorf("remote run: yum failed: %w", err)
|
||||
default:
|
||||
return fmt.Errorf("remote run: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// super simple escaping for a here-string; avoids quoting hell
|
||||
func sshEscape(s string) string {
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
105
internal/bg/bg.go
Normal file
105
internal/bg/bg.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dyaksa/archer"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Jobs struct{ Client *archer.Client }
|
||||
|
||||
func archerOptionsFromDSN(dsn string) (*archer.Options, error) {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user, pass string
|
||||
if u.User != nil {
|
||||
user = u.User.Username()
|
||||
pass, _ = u.User.Password()
|
||||
}
|
||||
|
||||
host := u.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host = net.JoinHostPort(host, "5432")
|
||||
}
|
||||
|
||||
return &archer.Options{
|
||||
Addr: host,
|
||||
User: user,
|
||||
Password: pass,
|
||||
DBName: strings.TrimPrefix(u.Path, "/"),
|
||||
SSL: u.Query().Get("sslmode"), // forward sslmode
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
|
||||
opts, err := archerOptionsFromDSN(dbUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
instances := viper.GetInt("archer.instances")
|
||||
if instances <= 0 {
|
||||
instances = 1
|
||||
}
|
||||
|
||||
timeoutSec := viper.GetInt("archer.timeoutSec")
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 60
|
||||
}
|
||||
|
||||
retainDays := viper.GetInt("archer.cleanup_retain_days")
|
||||
if retainDays <= 0 {
|
||||
retainDays = 7
|
||||
}
|
||||
|
||||
c := archer.NewClient(
|
||||
opts,
|
||||
archer.WithSetTableName("jobs"), // <- ensure correct table
|
||||
archer.WithSleepInterval(1*time.Second), // fast poll while debugging
|
||||
archer.WithErrHandler(func(err error) { // bubble up worker SQL errors
|
||||
log.Printf("[archer] ERROR: %v", err)
|
||||
}),
|
||||
)
|
||||
|
||||
jobs := &Jobs{Client: c}
|
||||
|
||||
c.Register(
|
||||
"bootstrap_bastion",
|
||||
BastionBootstrapWorker(gdb),
|
||||
archer.WithInstances(instances),
|
||||
archer.WithTimeout(time.Duration(timeoutSec)*time.Second),
|
||||
)
|
||||
|
||||
c.Register(
|
||||
"archer_cleanup",
|
||||
CleanupWorker(gdb, jobs, retainDays),
|
||||
archer.WithInstances(1),
|
||||
archer.WithTimeout(5*time.Minute),
|
||||
)
|
||||
|
||||
c.Register(
|
||||
"tokens_cleanup",
|
||||
TokensCleanupWorker(gdb, jobs),
|
||||
archer.WithInstances(1),
|
||||
archer.WithTimeout(5*time.Minute),
|
||||
)
|
||||
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (j *Jobs) Start() error { return j.Client.Start() }
|
||||
func (j *Jobs) Stop() { j.Client.Stop() }
|
||||
|
||||
func (j *Jobs) Enqueue(ctx context.Context, id, queue string, args any, opts ...archer.FnOptions) (any, error) {
|
||||
return j.Client.Schedule(ctx, id, queue, args, opts...)
|
||||
}
|
||||
51
internal/bg/tokens_cleanup.go
Normal file
51
internal/bg/tokens_cleanup.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/dyaksa/archer"
|
||||
"github.com/dyaksa/archer/job"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RefreshTokenRow struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
RevokedAt *time.Time
|
||||
ExpiresAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (RefreshTokenRow) TableName() string { return "refresh_tokens" }
|
||||
|
||||
type TokensCleanupArgs struct {
|
||||
// kept in case you want to change retention or add dry-run later
|
||||
}
|
||||
|
||||
func TokensCleanupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
||||
return func(ctx context.Context, j job.Job) (any, error) {
|
||||
if err := CleanupRefreshTokens(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// schedule tomorrow 03:45
|
||||
next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 45*time.Minute)
|
||||
_, _ = jobs.Enqueue(
|
||||
ctx,
|
||||
uuid.NewString(),
|
||||
"tokens_cleanup",
|
||||
TokensCleanupArgs{},
|
||||
archer.WithScheduleTime(next),
|
||||
archer.WithMaxRetries(1),
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func CleanupRefreshTokens(db *gorm.DB) error {
|
||||
now := time.Now()
|
||||
return db.
|
||||
Where("revoked_at IS NOT NULL OR expires_at < ?", now).
|
||||
Delete(&RefreshTokenRow{}).Error
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
)
|
||||
|
||||
type AuditFields struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid()"`
|
||||
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty" gorm:"column:created_at;not null;default:now()"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"autoUpdateTime;column:updated_at;not null;default:now()"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListAnnotations godoc
|
||||
// @ID ListAnnotations
|
||||
// @Summary List annotations (org scoped)
|
||||
@@ -11,11 +25,86 @@ package handlers
|
||||
// @Param name query string false "Exact name"
|
||||
// @Param value query string false "Exact value"
|
||||
// @Param q query string false "name contains (case-insensitive)"
|
||||
// @Success 200 {array} annotationResponse
|
||||
// @Success 200 {array} dto.AnnotationResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "failed to list annotations"
|
||||
// @Router /api/v1/annotations [get]
|
||||
// @Router /annotations [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func ListAnnotations(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
q := db.Where("organization_id = ?", orgID)
|
||||
|
||||
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
|
||||
q = q.Where(`key = ?`, key)
|
||||
}
|
||||
if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
|
||||
q = q.Where(`value = ?`, val)
|
||||
}
|
||||
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
|
||||
q = q.Where(`key ILIKE ?`, "%"+needle+"%")
|
||||
}
|
||||
|
||||
var out []dto.AnnotationResponse
|
||||
if err := q.Model(&models.Annotation{}).Order("created_at DESC").Scan(&out).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAnnotation godoc
|
||||
// @ID GetAnnotation
|
||||
// @Summary Get annotation by ID (org scoped)
|
||||
// @Description Returns one annotation. Add `include=node_pools` to include node pools.
|
||||
// @Tags Annotations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Annotation ID (UUID)"
|
||||
// @Param include query string false "Optional: node_pools"
|
||||
// @Success 200 {object} dto.AnnotationResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Router /annotations/{id} [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func GetAnnotation(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
var out dto.AnnotationResponse
|
||||
if err := db.Model(&models.Annotation{}).Where("id = ? AND organization_id = ?", id, orgID).First(&out).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dto
|
||||
|
||||
import "github.com/google/uuid"
|
||||
import (
|
||||
"github.com/glueops/autoglue/internal/common"
|
||||
)
|
||||
|
||||
type CreateSSHRequest struct {
|
||||
Name string `json:"name"`
|
||||
@@ -10,13 +12,13 @@ type CreateSSHRequest struct {
|
||||
}
|
||||
|
||||
type SshResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
common.AuditFields
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
EncryptedPrivateKey string `json:"-"`
|
||||
PrivateIV string `json:"-"`
|
||||
PrivateTag string `json:"-"`
|
||||
}
|
||||
|
||||
type SshRevealResponse struct {
|
||||
|
||||
24
internal/handlers/health.go
Normal file
24
internal/handlers/health.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
)
|
||||
|
||||
type HealthStatus struct {
|
||||
Status string `json:"status" example:"ok"`
|
||||
}
|
||||
|
||||
// HealthCheck godoc
|
||||
// @Summary Basic health check
|
||||
// @Description Returns 200 OK when the service is up
|
||||
// @Tags Health
|
||||
// @ID HealthCheck // operationId
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} HealthStatus
|
||||
// @Router /healthz [get]
|
||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
utils.WriteJSON(w, http.StatusOK, HealthStatus{Status: "ok"})
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"github.com/glueops/autoglue/internal/common"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
@@ -49,25 +49,18 @@ func ListPublicSshKeys(db *gorm.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var rows []models.SshKey
|
||||
if err := db.Where("organization_id = ?", orgID).Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
var out []dto.SshResponse
|
||||
if err := db.
|
||||
Model(&models.SshKey{}).
|
||||
Where("organization_id = ?", orgID).
|
||||
// avoid selecting encrypted columns here
|
||||
Select("id", "organization_id", "name", "public_key", "fingerprint", "created_at", "updated_at").
|
||||
Order("created_at DESC").
|
||||
Scan(&out).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list ssh keys")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.SshResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, dto.SshResponse{
|
||||
ID: row.ID,
|
||||
OrganizationID: row.OrganizationID,
|
||||
Name: row.Name,
|
||||
PublicKey: row.PublicKey,
|
||||
Fingerprint: row.Fingerprint,
|
||||
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
@@ -160,7 +153,9 @@ func CreateSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
fp := ssh.FingerprintSHA256(parsed)
|
||||
|
||||
key := models.SshKey{
|
||||
OrganizationID: orgID,
|
||||
AuditFields: common.AuditFields{
|
||||
OrganizationID: orgID,
|
||||
},
|
||||
Name: req.Name,
|
||||
PublicKey: pubAuth,
|
||||
EncryptedPrivateKey: cipher,
|
||||
@@ -175,13 +170,10 @@ func CreateSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusCreated, dto.SshResponse{
|
||||
ID: key.ID,
|
||||
OrganizationID: key.OrganizationID,
|
||||
Name: key.Name,
|
||||
PublicKey: key.PublicKey,
|
||||
Fingerprint: key.Fingerprint,
|
||||
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
AuditFields: key.AuditFields,
|
||||
Name: key.Name,
|
||||
PublicKey: key.PublicKey,
|
||||
Fingerprint: key.Fingerprint,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -221,30 +213,47 @@ func GetSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var key models.SshKey
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&key).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
reveal := strings.EqualFold(r.URL.Query().Get("reveal"), "true")
|
||||
|
||||
if !reveal {
|
||||
var out dto.SshResponse
|
||||
if err := db.
|
||||
Model(&models.SshKey{}).
|
||||
Where("id = ? AND organization_id = ?", id, orgID).
|
||||
Select("id", "organization_id", "name", "public_key", "fingerprint", "created_at", "updated_at").
|
||||
Limit(1).
|
||||
Scan(&out).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
|
||||
return
|
||||
}
|
||||
if out.ID == uuid.Nil {
|
||||
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
var secret dto.SshResponse
|
||||
if err := db.
|
||||
Model(&models.SshKey{}).
|
||||
Where("id = ? AND organization_id = ?", id, orgID).
|
||||
// include the encrypted bits too
|
||||
Select("id", "organization_id", "name", "public_key", "fingerprint",
|
||||
"encrypted_private_key", "private_iv", "private_tag",
|
||||
"created_at", "updated_at").
|
||||
Limit(1).
|
||||
Scan(&secret).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("reveal") != "true" {
|
||||
utils.WriteJSON(w, http.StatusOK, dto.SshResponse{
|
||||
ID: key.ID,
|
||||
OrganizationID: key.OrganizationID,
|
||||
Name: key.Name,
|
||||
PublicKey: key.PublicKey,
|
||||
Fingerprint: key.Fingerprint,
|
||||
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
if secret.ID == uuid.Nil {
|
||||
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
|
||||
return
|
||||
}
|
||||
|
||||
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
|
||||
plain, err := utils.DecryptForOrg(orgID, secret.EncryptedPrivateKey, secret.PrivateIV, secret.PrivateTag, db)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
|
||||
return
|
||||
@@ -252,13 +261,10 @@ func GetSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, dto.SshRevealResponse{
|
||||
SshResponse: dto.SshResponse{
|
||||
ID: key.ID,
|
||||
OrganizationID: key.OrganizationID,
|
||||
Name: key.Name,
|
||||
PublicKey: key.PublicKey,
|
||||
Fingerprint: key.Fingerprint,
|
||||
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
AuditFields: secret.AuditFields,
|
||||
Name: secret.Name,
|
||||
PublicKey: secret.PublicKey,
|
||||
Fingerprint: secret.Fingerprint,
|
||||
},
|
||||
PrivateKey: plain,
|
||||
})
|
||||
@@ -297,11 +303,16 @@ func DeleteSSHKey(db *gorm.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).
|
||||
Delete(&models.SshKey{}).Error; err != nil {
|
||||
res := db.Where("id = ? AND organization_id = ?", id, orgID).
|
||||
Delete(&models.SshKey{})
|
||||
if res.Error != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete ssh key")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,24 +55,11 @@ func ListTaints(db *gorm.DB) http.HandlerFunc {
|
||||
q = q.Where(`key ILIKE ?`, "%"+needle+"%")
|
||||
}
|
||||
|
||||
var rows []models.Taint
|
||||
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
var out []dto.TaintResponse
|
||||
if err := q.Model(&models.Taint{}).Order("created_at DESC").Find(&out).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.TaintResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, dto.TaintResponse{
|
||||
ID: row.ID,
|
||||
Key: row.Key,
|
||||
Value: row.Value,
|
||||
Effect: row.Effect,
|
||||
OrganizationID: row.OrganizationID,
|
||||
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
@@ -109,8 +96,8 @@ func GetTaint(db *gorm.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var row models.Taint
|
||||
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
|
||||
var out dto.TaintResponse
|
||||
if err := db.Model(&models.Taint{}).Where("id = ? AND organization_id = ?", id, orgID).First(&out).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
|
||||
return
|
||||
@@ -118,15 +105,7 @@ func GetTaint(db *gorm.DB) http.HandlerFunc {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
out := dto.TaintResponse{
|
||||
ID: row.ID,
|
||||
Key: row.Key,
|
||||
Value: row.Value,
|
||||
Effect: row.Effect,
|
||||
OrganizationID: row.OrganizationID,
|
||||
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@ type APIKey struct {
|
||||
Revoked bool `gorm:"not null;default:false" json:"revoked"`
|
||||
Prefix *string `json:"prefix,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
|
||||
}
|
||||
|
||||
23
internal/models/job.go
Normal file
23
internal/models/job.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
ID string `gorm:"type:varchar;primaryKey" json:"id"` // no default; supply from app
|
||||
QueueName string `gorm:"type:varchar;not null" json:"queue_name"`
|
||||
Status string `gorm:"type:varchar;not null" json:"status"`
|
||||
Arguments datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'"`
|
||||
Result datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'"`
|
||||
LastError *string `gorm:"type:varchar"`
|
||||
RetryCount int `gorm:"not null;default:0"`
|
||||
MaxRetry int `gorm:"not null;default:0"`
|
||||
RetryInterval int `gorm:"not null;default:0"`
|
||||
ScheduledAt time.Time `gorm:"type:timestamptz;default:now();index"`
|
||||
StartedAt *time.Time `gorm:"type:timestamptz;index"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz;column:created_at;not null;default:now()"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/glueops/autoglue/internal/common"
|
||||
)
|
||||
|
||||
type SshKey struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
common.AuditFields
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
PublicKey string `gorm:"not null"`
|
||||
@@ -16,6 +13,4 @@ type SshKey struct {
|
||||
PrivateIV string `gorm:"not null"`
|
||||
PrivateTag string `gorm:"not null"`
|
||||
Fingerprint string `gorm:"not null;index" json:"fingerprint"`
|
||||
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user