···55 "fmt"
66 "log"
77 "net/http"
88+ "os"
99+ "os/signal"
1010+ "syscall"
1111+ "time"
812913 "atcr.io/pkg/hold"
1014 "atcr.io/pkg/hold/oci"
···122126 WriteTimeout: cfg.Server.WriteTimeout,
123127 }
124128125125- // Start server in goroutine so we can do auto-registration after it's running
129129+ // Set up signal handling for graceful shutdown
130130+ sigChan := make(chan os.Signal, 1)
131131+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
132132+133133+ // Start server in goroutine
126134 serverErr := make(chan error, 1)
127135 go func() {
128136 log.Printf("Starting hold service on %s", cfg.Server.Addr)
···131139 }
132140 }()
133141134134- // Wait for server error or shutdown
135135- if err := <-serverErr; err != nil {
142142+ // Update status post to "online" after server starts
143143+ if holdPDS != nil {
144144+ ctx := context.Background()
145145+ if err := holdPDS.SetStatus(ctx, "online"); err != nil {
146146+ log.Printf("Warning: Failed to set status post to online: %v", err)
147147+ } else {
148148+ log.Printf("Status post set to online")
149149+ }
150150+ }
151151+152152+ // Wait for signal or server error
153153+ select {
154154+ case err := <-serverErr:
136155 log.Fatalf("Server failed: %v", err)
156156+ case sig := <-sigChan:
157157+ log.Printf("Received signal %v, shutting down gracefully...", sig)
158158+159159+ // Update status post to "offline" before shutdown
160160+ if holdPDS != nil {
161161+ ctx := context.Background()
162162+ if err := holdPDS.SetStatus(ctx, "offline"); err != nil {
163163+ log.Printf("Warning: Failed to set status post to offline: %v", err)
164164+ } else {
165165+ log.Printf("Status post set to offline")
166166+ }
167167+ }
168168+169169+ // Graceful shutdown with 10 second timeout
170170+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
171171+ defer cancel()
172172+173173+ if err := server.Shutdown(shutdownCtx); err != nil {
174174+ log.Printf("Server shutdown error: %v", err)
175175+ } else {
176176+ log.Printf("Server shutdown complete")
177177+ }
137178 }
138179}
+96
pkg/hold/pds/status.go
···11+package pds
22+33+import (
44+ "context"
55+ "fmt"
66+ "time"
77+88+ bsky "github.com/bluesky-social/indigo/api/bsky"
99+ "github.com/ipfs/go-cid"
1010+)
1111+1212+const (
1313+ // StatusPostRkey is the fixed rkey for the status post (singleton)
1414+ StatusPostRkey = "status"
1515+1616+ // StatusPostCollection is the collection name for Bluesky posts
1717+ StatusPostCollection = "app.bsky.feed.post"
1818+)
1919+2020+// SetStatus creates or updates the hold's status post on Bluesky
2121+// status should be "online" or "offline"
2222+func (p *HoldPDS) SetStatus(ctx context.Context, status string) error {
2323+ // Format the post text with emoji indicator
2424+ emoji := "🟢"
2525+ if status == "offline" {
2626+ emoji = "🔴"
2727+ }
2828+ text := fmt.Sprintf("%s Current status: %s", emoji, status)
2929+3030+ // Check if status post already exists
3131+ _, existingPost, err := p.GetStatusPost(ctx)
3232+ if err != nil {
3333+ // Post doesn't exist, create it
3434+ return p.createStatusPost(ctx, text)
3535+ }
3636+3737+ // Post exists, update it
3838+ // We need to preserve the original CreatedAt timestamp
3939+ return p.updateStatusPost(ctx, text, existingPost.CreatedAt)
4040+}
4141+4242+// GetStatusPost retrieves the status post if it exists
4343+func (p *HoldPDS) GetStatusPost(ctx context.Context) (cid.Cid, *bsky.FeedPost, error) {
4444+ // Use repomgr.GetRecord
4545+ recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, cid.Undef)
4646+ if err != nil {
4747+ return cid.Undef, nil, fmt.Errorf("failed to get status post: %w", err)
4848+ }
4949+5050+ // Type assert to bsky.FeedPost
5151+ post, ok := val.(*bsky.FeedPost)
5252+ if !ok {
5353+ return cid.Undef, nil, fmt.Errorf("unexpected type for status post: %T", val)
5454+ }
5555+5656+ return recordCID, post, nil
5757+}
5858+5959+// createStatusPost creates a new status post (first time)
6060+func (p *HoldPDS) createStatusPost(ctx context.Context, text string) error {
6161+ // Create post struct
6262+ now := time.Now().Format(time.RFC3339)
6363+ post := &bsky.FeedPost{
6464+ LexiconTypeID: "app.bsky.feed.post",
6565+ Text: text,
6666+ CreatedAt: now,
6767+ }
6868+6969+ // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists
7070+ recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, post)
7171+ if err != nil {
7272+ return fmt.Errorf("failed to create status post: %w", err)
7373+ }
7474+7575+ fmt.Printf("Created status post at %s, cid: %s, text: %s\n", recordPath, recordCID, text)
7676+ return nil
7777+}
7878+7979+// updateStatusPost updates an existing status post
8080+func (p *HoldPDS) updateStatusPost(ctx context.Context, text string, createdAt string) error {
8181+ // Create updated post struct with original CreatedAt
8282+ post := &bsky.FeedPost{
8383+ LexiconTypeID: "app.bsky.feed.post",
8484+ Text: text,
8585+ CreatedAt: createdAt, // Preserve original creation time
8686+ }
8787+8888+ // Use repomgr.UpdateRecord
8989+ recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, post)
9090+ if err != nil {
9191+ return fmt.Errorf("failed to update status post: %w", err)
9292+ }
9393+9494+ fmt.Printf("Updated status post, cid: %s, text: %s\n", recordCID, text)
9595+ return nil
9696+}
+162
pkg/hold/pds/status_test.go
···11+package pds
22+33+import (
44+ "context"
55+ "os"
66+ "path/filepath"
77+ "testing"
88+99+ bsky "github.com/bluesky-social/indigo/api/bsky"
1010+)
1111+1212+func TestStatusPost(t *testing.T) {
1313+ // Create temporary directory for test database
1414+ tmpDir := t.TempDir()
1515+ dbPath := filepath.Join(tmpDir, "test.db")
1616+ keyPath := filepath.Join(tmpDir, "test.key")
1717+1818+ // Create test PDS
1919+ ctx := context.Background()
2020+ did := "did:web:test.example.com"
2121+ publicURL := "https://test.example.com"
2222+2323+ holdPDS, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath)
2424+ if err != nil {
2525+ t.Fatalf("Failed to create test PDS: %v", err)
2626+ }
2727+2828+ // Initialize empty repo (required before creating records)
2929+ err = holdPDS.repomgr.InitNewActor(ctx, holdPDS.uid, "", did, "", "", "")
3030+ if err != nil {
3131+ t.Fatalf("Failed to initialize repo: %v", err)
3232+ }
3333+3434+ t.Run("CreateStatusPost", func(t *testing.T) {
3535+ // Set status to online (creates new post)
3636+ err := holdPDS.SetStatus(ctx, "online")
3737+ if err != nil {
3838+ t.Fatalf("Failed to set status to online: %v", err)
3939+ }
4040+4141+ // Verify post was created
4242+ _, post, err := holdPDS.GetStatusPost(ctx)
4343+ if err != nil {
4444+ t.Fatalf("Failed to get status post: %v", err)
4545+ }
4646+4747+ if post.Text != "🟢 Current status: online" {
4848+ t.Errorf("Expected text '🟢 Current status: online', got '%s'", post.Text)
4949+ }
5050+5151+ if post.LexiconTypeID != "app.bsky.feed.post" {
5252+ t.Errorf("Expected LexiconTypeID 'app.bsky.feed.post', got '%s'", post.LexiconTypeID)
5353+ }
5454+5555+ if post.CreatedAt == "" {
5656+ t.Error("CreatedAt should not be empty")
5757+ }
5858+ })
5959+6060+ t.Run("UpdateStatusPost", func(t *testing.T) {
6161+ // Get the original post to check CreatedAt preservation
6262+ _, originalPost, err := holdPDS.GetStatusPost(ctx)
6363+ if err != nil {
6464+ t.Fatalf("Failed to get original status post: %v", err)
6565+ }
6666+6767+ // Set status to offline (updates existing post)
6868+ err = holdPDS.SetStatus(ctx, "offline")
6969+ if err != nil {
7070+ t.Fatalf("Failed to set status to offline: %v", err)
7171+ }
7272+7373+ // Verify post was updated
7474+ _, post, err := holdPDS.GetStatusPost(ctx)
7575+ if err != nil {
7676+ t.Fatalf("Failed to get updated status post: %v", err)
7777+ }
7878+7979+ if post.Text != "🔴 Current status: offline" {
8080+ t.Errorf("Expected text '🔴 Current status: offline', got '%s'", post.Text)
8181+ }
8282+8383+ // Verify CreatedAt was preserved
8484+ if post.CreatedAt != originalPost.CreatedAt {
8585+ t.Errorf("CreatedAt should be preserved. Expected '%s', got '%s'", originalPost.CreatedAt, post.CreatedAt)
8686+ }
8787+ })
8888+8989+ t.Run("ToggleStatus", func(t *testing.T) {
9090+ // Toggle back to online
9191+ err := holdPDS.SetStatus(ctx, "online")
9292+ if err != nil {
9393+ t.Fatalf("Failed to set status to online: %v", err)
9494+ }
9595+9696+ _, post, err := holdPDS.GetStatusPost(ctx)
9797+ if err != nil {
9898+ t.Fatalf("Failed to get status post: %v", err)
9999+ }
100100+101101+ if post.Text != "🟢 Current status: online" {
102102+ t.Errorf("Expected text '🟢 Current status: online', got '%s'", post.Text)
103103+ }
104104+ })
105105+}
106106+107107+func TestStatusPostCollection(t *testing.T) {
108108+ // Verify constants
109109+ if StatusPostCollection != "app.bsky.feed.post" {
110110+ t.Errorf("Expected StatusPostCollection 'app.bsky.feed.post', got '%s'", StatusPostCollection)
111111+ }
112112+113113+ if StatusPostRkey != "status" {
114114+ t.Errorf("Expected StatusPostRkey 'status', got '%s'", StatusPostRkey)
115115+ }
116116+}
117117+118118+func TestGetStatusPostNotExists(t *testing.T) {
119119+ // Create temporary directory for test database
120120+ tmpDir := t.TempDir()
121121+ dbPath := filepath.Join(tmpDir, "test.db")
122122+ keyPath := filepath.Join(tmpDir, "test.key")
123123+124124+ // Create test PDS
125125+ ctx := context.Background()
126126+ did := "did:web:test2.example.com"
127127+ publicURL := "https://test2.example.com"
128128+129129+ holdPDS, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath)
130130+ if err != nil {
131131+ t.Fatalf("Failed to create test PDS: %v", err)
132132+ }
133133+134134+ // Initialize empty repo
135135+ err = holdPDS.repomgr.InitNewActor(ctx, holdPDS.uid, "", did, "", "", "")
136136+ if err != nil {
137137+ t.Fatalf("Failed to initialize repo: %v", err)
138138+ }
139139+140140+ // Try to get status post that doesn't exist
141141+ _, _, err = holdPDS.GetStatusPost(ctx)
142142+ if err == nil {
143143+ t.Error("Expected error when getting non-existent status post, got nil")
144144+ }
145145+}
146146+147147+func init() {
148148+ // Register FeedPost type for testing
149149+ // This is normally done by the bsky package init(), but we need to ensure it's done
150150+ // The actual registration happens in the bsky package, so this is a no-op
151151+ // but kept for clarity
152152+ _ = &bsky.FeedPost{}
153153+}
154154+155155+// Cleanup function to remove test files
156156+func TestMain(m *testing.M) {
157157+ // Run tests
158158+ code := m.Run()
159159+160160+ // Cleanup is automatic with t.TempDir()
161161+ os.Exit(code)
162162+}