feat: adding background jobs, Dockerfile

This commit is contained in:
allanice001
2025-11-04 16:32:54 +00:00
parent 2170b9a945
commit 19d5cf7aab
34 changed files with 1269 additions and 148 deletions

View File

@@ -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))

View File

@@ -22,6 +22,7 @@ func NewRuntime() *Runtime {
d := db.Open(cfg.DbURL)
err = db.Run(d,
&models.Job{},
&models.MasterKey{},
&models.SigningKey{},
&models.User{},

View 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
View 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
View 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...)
}

View 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
}

View File

@@ -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()"`
}

View File

@@ -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)
}
}

View File

@@ -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 {

View 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"})
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
View 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()"`
}

View File

@@ -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"`
}