Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitb2760b1

Browse files
authored
feat: send native system notification on scheduled workspace shutdown (#1414)
* feat: send native system notification on scheduled workspace shutdownThis commit adds a fairly generic notification package and uses itto notify users connected over SSH of pending workspace shutdowns.Only one notification will be sent at most 5 minutes prior to the scheduledshutdown, and only one CLI instance will send notifications if multipleinstances are running.
1 parent4ab7a41 commitb2760b1

File tree

5 files changed

+309
-0
lines changed

5 files changed

+309
-0
lines changed

‎cli/ssh.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package cli
22

33
import (
44
"context"
5+
"fmt"
56
"io"
67
"os"
8+
"path/filepath"
79
"strings"
10+
"time"
811

12+
"github.com/gen2brain/beeep"
13+
"github.com/gofrs/flock"
914
"github.com/google/uuid"
1015
"github.com/mattn/go-isatty"
1116
"github.com/spf13/cobra"
@@ -15,10 +20,15 @@ import (
1520

1621
"github.com/coder/coder/cli/cliflag"
1722
"github.com/coder/coder/cli/cliui"
23+
"github.com/coder/coder/coderd/autobuild/notify"
24+
"github.com/coder/coder/coderd/autobuild/schedule"
1825
"github.com/coder/coder/coderd/database"
1926
"github.com/coder/coder/codersdk"
2027
)
2128

29+
varautostopPollInterval=30*time.Second
30+
varautostopNotifyCountdown= []time.Duration{5*time.Minute}
31+
2232
funcssh()*cobra.Command {
2333
var (
2434
stdiobool
@@ -108,6 +118,9 @@ func ssh() *cobra.Command {
108118
}
109119
deferconn.Close()
110120

121+
stopPolling:=tryPollWorkspaceAutostop(cmd.Context(),client,workspace)
122+
deferstopPolling()
123+
111124
ifstdio {
112125
rawSSH,err:=conn.SSH()
113126
iferr!=nil {
@@ -179,3 +192,57 @@ func ssh() *cobra.Command {
179192

180193
returncmd
181194
}
195+
196+
// Attempt to poll workspace autostop. We write a per-workspace lockfile to
197+
// avoid spamming the user with notifications in case of multiple instances
198+
// of the CLI running simultaneously.
199+
functryPollWorkspaceAutostop(ctx context.Context,client*codersdk.Client,workspace codersdk.Workspace) (stopfunc()) {
200+
lock:=flock.New(filepath.Join(os.TempDir(),"coder-autostop-notify-"+workspace.ID.String()))
201+
condition:=notifyCondition(ctx,client,workspace.ID,lock)
202+
returnnotify.Notify(condition,autostopPollInterval,autostopNotifyCountdown...)
203+
}
204+
205+
// Notify the user if the workspace is due to shutdown.
206+
funcnotifyCondition(ctx context.Context,client*codersdk.Client,workspaceID uuid.UUID,lock*flock.Flock) notify.Condition {
207+
returnfunc(now time.Time) (deadline time.Time,callbackfunc()) {
208+
// Keep trying to regain the lock.
209+
locked,err:=lock.TryLockContext(ctx,autostopPollInterval)
210+
iferr!=nil||!locked {
211+
return time.Time{},nil
212+
}
213+
214+
ws,err:=client.Workspace(ctx,workspaceID)
215+
iferr!=nil {
216+
return time.Time{},nil
217+
}
218+
219+
ifws.AutostopSchedule=="" {
220+
return time.Time{},nil
221+
}
222+
223+
sched,err:=schedule.Weekly(ws.AutostopSchedule)
224+
iferr!=nil {
225+
return time.Time{},nil
226+
}
227+
228+
deadline=sched.Next(now)
229+
callback=func() {
230+
ttl:=deadline.Sub(now)
231+
vartitle,bodystring
232+
ifttl>time.Minute {
233+
title=fmt.Sprintf(`Workspace %s stopping in %.0f mins`,ws.Name,ttl.Minutes())
234+
body=fmt.Sprintf(
235+
`Your Coder workspace %s is scheduled to stop at %s.`,
236+
ws.Name,
237+
deadline.Format(time.Kitchen),
238+
)
239+
}else {
240+
title=fmt.Sprintf("Workspace %s stopping!",ws.Name)
241+
body=fmt.Sprintf("Your Coder workspace %s is stopping any time now!",ws.Name)
242+
}
243+
// notify user with a native system notification (best effort)
244+
_=beeep.Notify(title,body,"")
245+
}
246+
returndeadline.Truncate(time.Minute),callback
247+
}
248+
}

‎coderd/autobuild/notify/notifier.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package notify
2+
3+
import (
4+
"sort"
5+
"sync"
6+
"time"
7+
)
8+
9+
// Notifier calls a Condition at most once for each count in countdown.
10+
typeNotifierstruct {
11+
lock sync.Mutex
12+
conditionCondition
13+
notifiedAtmap[time.Duration]bool
14+
countdown []time.Duration
15+
}
16+
17+
// Condition is a function that gets executed with a certain time.
18+
// - It should return the deadline for the notification, as well as a
19+
// callback function to execute once the time to the deadline is
20+
// less than one of the notify attempts. If deadline is the zero
21+
// time, callback will not be executed.
22+
// - Callback is executed once for every time the difference between deadline
23+
// and the current time is less than an element of countdown.
24+
// - To enforce a minimum interval between consecutive callbacks, truncate
25+
// the returned deadline to the minimum interval.
26+
typeConditionfunc(now time.Time) (deadline time.Time,callbackfunc())
27+
28+
// Notify is a convenience function that initializes a new Notifier
29+
// with the given condition, interval, and countdown.
30+
// It is the responsibility of the caller to call close to stop polling.
31+
funcNotify(condCondition,interval time.Duration,countdown...time.Duration) (closefunc()) {
32+
notifier:=New(cond,countdown...)
33+
ticker:=time.NewTicker(interval)
34+
gonotifier.Poll(ticker.C)
35+
returnticker.Stop
36+
}
37+
38+
// New returns a Notifier that calls cond once every time it polls.
39+
// - Duplicate values are removed from countdown, and it is sorted in
40+
// descending order.
41+
funcNew(condCondition,countdown...time.Duration)*Notifier {
42+
// Ensure countdown is sorted in descending order and contains no duplicates.
43+
ct:=unique(countdown)
44+
sort.Slice(ct,func(i,jint)bool {
45+
returnct[i]<ct[j]
46+
})
47+
48+
n:=&Notifier{
49+
countdown:ct,
50+
condition:cond,
51+
notifiedAt:make(map[time.Duration]bool),
52+
}
53+
54+
returnn
55+
}
56+
57+
// Poll polls once immediately, and then once for every value from ticker.
58+
// Poll exits when ticker is closed.
59+
func (n*Notifier)Poll(ticker<-chan time.Time) {
60+
// poll once immediately
61+
n.pollOnce(time.Now())
62+
fort:=rangeticker {
63+
n.pollOnce(t)
64+
}
65+
}
66+
67+
func (n*Notifier)pollOnce(tick time.Time) {
68+
n.lock.Lock()
69+
defern.lock.Unlock()
70+
71+
deadline,callback:=n.condition(tick)
72+
ifdeadline.IsZero() {
73+
return
74+
}
75+
76+
timeRemaining:=deadline.Sub(tick)
77+
for_,tock:=rangen.countdown {
78+
ifn.notifiedAt[tock] {
79+
continue
80+
}
81+
iftimeRemaining>tock {
82+
continue
83+
}
84+
callback()
85+
n.notifiedAt[tock]=true
86+
return
87+
}
88+
}
89+
90+
funcunique(ds []time.Duration) []time.Duration {
91+
m:=make(map[time.Duration]bool)
92+
for_,d:=rangeds {
93+
m[d]=true
94+
}
95+
varks []time.Duration
96+
fork:=rangem {
97+
ks=append(ks,k)
98+
}
99+
returnks
100+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package notify_test
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
"go.uber.org/atomic"
10+
"go.uber.org/goleak"
11+
12+
"github.com/coder/coder/coderd/autobuild/notify"
13+
)
14+
15+
funcTestNotifier(t*testing.T) {
16+
t.Parallel()
17+
18+
now:=time.Now()
19+
20+
testCases:= []struct {
21+
Namestring
22+
Countdown []time.Duration
23+
Ticks []time.Time
24+
ConditionDeadline time.Time
25+
NumConditionsint64
26+
NumCallbacksint64
27+
}{
28+
{
29+
Name:"zero deadline",
30+
Countdown:durations(),
31+
Ticks:fakeTicker(now,time.Second,0),
32+
ConditionDeadline: time.Time{},
33+
NumConditions:1,
34+
NumCallbacks:0,
35+
},
36+
{
37+
Name:"no calls",
38+
Countdown:durations(),
39+
Ticks:fakeTicker(now,time.Second,0),
40+
ConditionDeadline:now,
41+
NumConditions:1,
42+
NumCallbacks:0,
43+
},
44+
{
45+
Name:"exactly one call",
46+
Countdown:durations(time.Second),
47+
Ticks:fakeTicker(now,time.Second,1),
48+
ConditionDeadline:now.Add(time.Second),
49+
NumConditions:2,
50+
NumCallbacks:1,
51+
},
52+
{
53+
Name:"two calls",
54+
Countdown:durations(4*time.Second,2*time.Second),
55+
Ticks:fakeTicker(now,time.Second,5),
56+
ConditionDeadline:now.Add(5*time.Second),
57+
NumConditions:6,
58+
NumCallbacks:2,
59+
},
60+
{
61+
Name:"wrong order should not matter",
62+
Countdown:durations(2*time.Second,4*time.Second),
63+
Ticks:fakeTicker(now,time.Second,5),
64+
ConditionDeadline:now.Add(5*time.Second),
65+
NumConditions:6,
66+
NumCallbacks:2,
67+
},
68+
{
69+
Name:"ssh autostop notify",
70+
Countdown:durations(5*time.Minute,time.Minute),
71+
Ticks:fakeTicker(now,30*time.Second,120),
72+
ConditionDeadline:now.Add(30*time.Minute),
73+
NumConditions:121,
74+
NumCallbacks:2,
75+
},
76+
}
77+
78+
for_,testCase:=rangetestCases {
79+
testCase:=testCase
80+
t.Run(testCase.Name,func(t*testing.T) {
81+
t.Parallel()
82+
ch:=make(chan time.Time)
83+
numConditions:=atomic.NewInt64(0)
84+
numCalls:=atomic.NewInt64(0)
85+
cond:=func(time.Time) (time.Time,func()) {
86+
numConditions.Inc()
87+
returntestCase.ConditionDeadline,func() {
88+
numCalls.Inc()
89+
}
90+
}
91+
varwg sync.WaitGroup
92+
gofunc() {
93+
n:=notify.New(cond,testCase.Countdown...)
94+
n.Poll(ch)
95+
wg.Done()
96+
}()
97+
wg.Add(1)
98+
for_,tick:=rangetestCase.Ticks {
99+
ch<-tick
100+
}
101+
close(ch)
102+
wg.Wait()
103+
require.Equal(t,testCase.NumCallbacks,numCalls.Load())
104+
require.Equal(t,testCase.NumConditions,numConditions.Load())
105+
})
106+
}
107+
}
108+
109+
funcdurations(ds...time.Duration) []time.Duration {
110+
returnds
111+
}
112+
113+
funcfakeTicker(t time.Time,d time.Duration,nint) []time.Time {
114+
varts []time.Time
115+
fori:=1;i<=n;i++ {
116+
ts=append(ts,t.Add(time.Duration(n)*d))
117+
}
118+
returnts
119+
}
120+
121+
funcTestMain(m*testing.M) {
122+
goleak.VerifyTestMain(m)
123+
}

‎go.mod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,13 @@ require (
5858
github.com/fatih/colorv1.13.0
5959
github.com/fatih/structsv1.1.0
6060
github.com/fullsailor/pkcs7v0.0.0-20190404230743-d7302db945fa
61+
github.com/gen2brain/beeepv0.0.0-20220402123239-6a3042f4b71a
6162
github.com/gliderlabs/sshv0.3.3
6263
github.com/go-chi/chi/v5v5.0.7
6364
github.com/go-chi/httpratev0.5.3
6465
github.com/go-chi/renderv1.0.1
6566
github.com/go-playground/validator/v10v10.11.0
67+
github.com/gofrs/flockv0.8.1
6668
github.com/gohugoio/hugov0.98.0
6769
github.com/golang-jwt/jwtv3.2.2+incompatible
6870
github.com/golang-migrate/migrate/v4v4.15.2
@@ -159,8 +161,10 @@ require (
159161
github.com/ghodss/yamlv1.0.0// indirect
160162
github.com/go-playground/localesv0.14.0// indirect
161163
github.com/go-playground/universal-translatorv0.18.0// indirect
164+
github.com/go-toast/toastv0.0.0-20190211030409-01e6764cf0a4// indirect
162165
github.com/gobwas/globv0.2.3// indirect
163166
github.com/gobwas/wsv1.1.0// indirect
167+
github.com/godbus/dbus/v5v5.1.0// indirect
164168
github.com/gogo/protobufv1.3.2// indirect
165169
github.com/golang/glogv1.0.0// indirect
166170
github.com/golang/groupcachev0.0.0-20210331224755-41bb18bfe9da// indirect
@@ -196,6 +200,7 @@ require (
196200
github.com/muesli/reflowv0.3.0// indirect
197201
github.com/muesli/termenvv0.11.1-0.20220212125758-44cd13922739// indirect
198202
github.com/niklasfasching/go-orgv1.6.2// indirect
203+
github.com/nu7hatch/gouuidv0.0.0-20131221200532-179d4d0c4d8d// indirect
199204
github.com/opencontainers/go-digestv1.0.0// indirect
200205
github.com/opencontainers/image-specv1.0.2// indirect
201206
github.com/opencontainers/runcv1.1.0// indirect
@@ -226,6 +231,7 @@ require (
226231
github.com/spf13/aferov1.8.2// indirect
227232
github.com/spf13/castv1.4.1// indirect
228233
github.com/spf13/jwalterweathermanv1.1.0// indirect
234+
github.com/tadvi/systrayv0.0.0-20190226123456-11a2b8fa57af// indirect
229235
github.com/templexxx/cpufeatv0.0.0-20180724012125-cef66df7f161// indirect
230236
github.com/templexxx/xorv0.0.0-20191217153810-f85b25db303b// indirect
231237
github.com/tinylib/msgpv1.1.2// indirect

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp