diff --git a/go.mod b/go.mod index e20281eaa..db6549f82 100644 --- a/go.mod +++ b/go.mod @@ -23,9 +23,9 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muesli/cancelreader v0.2.2 + github.com/oklog/ulid/v2 v2.1.0 github.com/rogpeppe/go-internal v1.11.0 github.com/rs/cors v1.10.1 - github.com/rs/xid v1.5.0 github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a github.com/vektah/gqlparser/v2 v2.5.10 github.com/yuin/goldmark v1.5.6 diff --git a/go.sum b/go.sum index 44bf88953..955c3e444 100644 --- a/go.sum +++ b/go.sum @@ -153,8 +153,11 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= @@ -175,8 +178,6 @@ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDN github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a h1:URwYffGNuBQkfwkcn+1CZhb8IE/mKSXxPXp/zzQsn80= github.com/rwtodd/Go.Sed v0.0.0-20230610052213-ba3e9c186f0a/go.mod h1:c6qgHcSUeSISur4+Kcf3WYTvpL07S8eAsoP40hDiQ1I= diff --git a/internal/idgen/id.go b/internal/idgen/id.go new file mode 100644 index 000000000..80d5cfe67 --- /dev/null +++ b/internal/idgen/id.go @@ -0,0 +1,63 @@ +// Package utils contains utility functions for the application +package idgen + +import ( + "io" + "regexp" + "sync" + "time" + + "github.com/oklog/ulid/v2" + "golang.org/x/exp/rand" +) + +var ( + entropy io.Reader + entropyOnce sync.Once +) + +// DefaultEntropy returns a reader that generates ULID entropy. +// The default entropy function utilizes math/rand.Rand, which is not safe for concurrent use by multiple goroutines. +// Therefore, this function employs x/exp/rand, as recommended by the authors of the library. +func DefaultEntropy() io.Reader { + entropyOnce.Do(func() { + seed := uint64(time.Now().UnixNano()) + source := rand.NewSource(seed) + rng := rand.New(source) + + entropy = &ulid.LockedMonotonicReader{ + MonotonicReader: ulid.Monotonic(rng, 0), + } + }) + return entropy +} + +// IsULID checks if the given string is a valid ULID +// ULID pattern: +// +// 01AN4Z07BY 79KA1307SR9X4MV3 +// |----------| |----------------| +// Timestamp Randomness +// +// 10 characters 16 characters +// Crockford's Base32 is used (excludes I, L, O, and U to avoid confusion and abuse) +func isULID(s string) bool { + ulidRegex := `^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$` + matched, _ := regexp.MatchString(ulidRegex, s) + return matched +} + +// ValidID checks if the given id is valid +func ValidID(id string) bool { + _, err := ulid.Parse(id) + + return err == nil && isULID(id) +} + +// GenerateID generates a new universal ID +func GenerateID() string { + entropy := DefaultEntropy() + now := time.Now() + ts := ulid.Timestamp(now) + return ulid.MustNew(ts, entropy).String() +} diff --git a/internal/idgen/id_test.go b/internal/idgen/id_test.go new file mode 100644 index 000000000..999a00717 --- /dev/null +++ b/internal/idgen/id_test.go @@ -0,0 +1,66 @@ +package idgen + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidID(t *testing.T) { + validULID := GenerateID() + + tests := []struct { + id string + expected bool + }{ + {validULID, true}, + {"0", false}, + {"invalidulid", false}, + {"invalidulid", false}, + {"01B4E6BXY0PRJ5G420D25MWQY!", false}, + } + + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + if got := ValidID(tt.id); got != tt.expected { + assert.Equal(t, tt.expected, got) + } + }) + } +} + +func TestGenerateUniqueID(t *testing.T) { + Generator := func() string { return GenerateID() } + + t.Run("uniqueness", func(t *testing.T) { + id1 := Generator() + id2 := Generator() + assert.NotEqual(t, id1, id2) + }) + + t.Run("concurrent uniqueness", func(t *testing.T) { + var wg sync.WaitGroup + ids := make(map[string]struct{}) + mu := sync.Mutex{} + + generateAndStoreID := func() { + defer wg.Done() + id := Generator() + mu.Lock() + defer mu.Unlock() + ids[id] = struct{}{} + } + + numIDs := 10000 + + wg.Add(numIDs) + for i := 0; i < numIDs; i++ { + go generateAndStoreID() + } + + wg.Wait() + + assert.Equal(t, numIDs, len(ids)) + }) +} diff --git a/internal/runner/command.go b/internal/runner/command.go index e61cfcbfd..320c3a890 100644 --- a/internal/runner/command.go +++ b/internal/runner/command.go @@ -15,7 +15,7 @@ import ( "github.com/creack/pty" "github.com/pkg/errors" - "github.com/rs/xid" + "github.com/stateful/runme/internal/idgen" "go.uber.org/multierr" "go.uber.org/zap" ) @@ -198,8 +198,8 @@ func newCommand(cfg *commandConfig) (*command, error) { extraArgs = append(extraArgs, "-c", script) case CommandModeTempFile: for { - id := xid.New() - tempScriptFile = filepath.Join(cfg.Directory, fmt.Sprintf(".runme-script-%s", id.String())) + id := idgen.GenerateID() + tempScriptFile = filepath.Join(cfg.Directory, fmt.Sprintf(".runme-script-%s", id)) if fileExtension != "" { tempScriptFile += "." + fileExtension diff --git a/internal/runner/service.go b/internal/runner/service.go index 16ddcd3f1..fa022921c 100644 --- a/internal/runner/service.go +++ b/internal/runner/service.go @@ -9,9 +9,9 @@ import ( "github.com/creack/pty" "github.com/pkg/errors" - "github.com/rs/xid" "github.com/stateful/runme/internal/env" runnerv1 "github.com/stateful/runme/internal/gen/proto/go/runme/runner/v1" + "github.com/stateful/runme/internal/idgen" "github.com/stateful/runme/internal/rbuffer" "github.com/stateful/runme/pkg/project" "go.uber.org/zap" @@ -155,7 +155,7 @@ func ConvertRunnerProject(runnerProj *runnerv1.Project) (project.Project, error) } func (r *runnerService) Execute(srv runnerv1.RunnerService_ExecuteServer) error { - logger := r.logger.With(zap.String("_id", xid.New().String())) + logger := r.logger.With(zap.String("_id", idgen.GenerateID())) logger.Info("running Execute in runnerService") diff --git a/internal/runner/session.go b/internal/runner/session.go index d553bf621..c35d7520f 100644 --- a/internal/runner/session.go +++ b/internal/runner/session.go @@ -5,7 +5,7 @@ import ( "sync" lru "github.com/hashicorp/golang-lru/v2" - "github.com/rs/xid" + "github.com/stateful/runme/internal/idgen" "github.com/stateful/runme/pkg/project" "go.uber.org/zap" ) @@ -36,7 +36,7 @@ func NewSession(envs []string, proj project.Project, logger *zap.Logger) (*Sessi } s := &Session{ - ID: xid.New().String(), + ID: idgen.GenerateID(), envStore: newEnvStore(sessionEnvs...), logger: logger, diff --git a/internal/runner/session_test.go b/internal/runner/session_test.go index 075fd8241..9afb5dcac 100644 --- a/internal/runner/session_test.go +++ b/internal/runner/session_test.go @@ -41,6 +41,9 @@ func Test_SessionList(t *testing.T) { session2, err := createSession() require.NoError(t, err) + + assert.NotEqual(t, session1.ID, session2.ID) + list.AddSession(session2) found, ok := list.GetSession(session1.ID) @@ -62,10 +65,19 @@ func Test_SessionList(t *testing.T) { session2, err := list.CreateAndAddSession(createSession) require.NoError(t, err) + assert.NotEqual(t, session1.ID, session2.ID) + sessions, err := list.ListSessions() require.NoError(t, err) - assert.Equal(t, []*Session{session1, session2}, sessions) + expected := []string{session1.ID, session2.ID} + actual := []string{} + + for _, session := range sessions { + actual = append(actual, session.ID) + } + + assert.Equal(t, expected, actual) }) t.Run("DeleteSession", func(t *testing.T) { @@ -78,6 +90,8 @@ func Test_SessionList(t *testing.T) { session2, err := list.CreateAndAddSession(createSession) require.NoError(t, err) + assert.NotEqual(t, session1.ID, session2.ID) + { sessionList, err := list.ListSessions() require.NoError(t, err) @@ -91,7 +105,15 @@ func Test_SessionList(t *testing.T) { sessionList, err := list.ListSessions() require.NoError(t, err) assert.Equal(t, 1, len(sessionList)) - assert.Equal(t, session1, sessionList[0]) + + expected := []string{session1.ID} + actual := []string{} + + for _, session := range sessionList { + actual = append(actual, session.ID) + } + + assert.Equal(t, expected, actual) } deleted = list.DeleteSession(session1.ID) diff --git a/internal/runner/shell_session.go b/internal/runner/shell_session.go index 6956a374a..f0354dfc8 100644 --- a/internal/runner/shell_session.go +++ b/internal/runner/shell_session.go @@ -8,7 +8,7 @@ import ( "github.com/creack/pty" "github.com/pkg/errors" - "github.com/rs/xid" + "github.com/stateful/runme/internal/idgen" xpty "github.com/stateful/runme/internal/pty" "golang.org/x/term" ) @@ -25,7 +25,7 @@ type ShellSession struct { } func NewShellSession(command string) (*ShellSession, error) { - id := xid.New().String() + id := idgen.GenerateID() cmd := exec.Command(command) cmd.Env = append(os.Environ(), "RUNMESHELL="+id)