@@ -21,6 +21,7 @@ package main // import "k8s.io/git-sync/cmd/git-sync"
21
21
import (
22
22
"context"
23
23
"crypto/md5"
24
+ "encoding/json"
24
25
"errors"
25
26
"fmt"
26
27
"io"
@@ -40,6 +41,7 @@ import (
40
41
"syscall"
41
42
"time"
42
43
44
+ "github.com/golang-jwt/jwt/v4"
43
45
"github.com/prometheus/client_golang/prometheus"
44
46
"github.com/prometheus/client_golang/prometheus/promhttp"
45
47
"github.com/spf13/pflag"
@@ -107,20 +109,21 @@ const defaultDirMode = os.FileMode(0775) // subject to umask
107
109
108
110
// repoSync represents the remote repo and the local sync of it.
109
111
type repoSync struct {
110
- cmd string // the git command to run
111
- root absPath // absolute path to the root directory
112
- repo string // remote repo to sync
113
- ref string // the ref to sync
114
- depth int // for shallow sync
115
- submodules submodulesMode // how to handle submodules
116
- gc gcMode // garbage collection
117
- link absPath // absolute path to the symlink to publish
118
- authURL string // a URL to re-fetch credentials, or ""
119
- sparseFile string // path to a sparse-checkout file
120
- syncCount int // how many times have we synced?
121
- log * logging.Logger
122
- run cmd.Runner
123
- staleTimeout time.Duration // time for worktrees to be cleaned up
112
+ cmd string // the git command to run
113
+ root absPath // absolute path to the root directory
114
+ repo string // remote repo to sync
115
+ ref string // the ref to sync
116
+ depth int // for shallow sync
117
+ submodules submodulesMode // how to handle submodules
118
+ gc gcMode // garbage collection
119
+ link absPath // absolute path to the symlink to publish
120
+ authURL string // a URL to re-fetch credentials, or ""
121
+ sparseFile string // path to a sparse-checkout file
122
+ syncCount int // how many times have we synced?
123
+ log * logging.Logger
124
+ run cmd.Runner
125
+ staleTimeout time.Duration // time for worktrees to be cleaned up
126
+ appTokenExpiry time.Time // time when github app auth token expires
124
127
}
125
128
126
129
func main () {
@@ -254,6 +257,19 @@ func main() {
254
257
envString ("" , "GITSYNC_ASKPASS_URL" , "GIT_SYNC_ASKPASS_URL" , "GIT_ASKPASS_URL" ),
255
258
"a URL to query for git credentials (username=<value> and password=<value>)" )
256
259
260
+ flGithubBaseURL := pflag .String ("github-base-url" ,
261
+ envString ("https://api.github.com/" , "GITSYNC_GITHUB_BASE_URL" ),
262
+ "the GitHub base URL to use when making requests to GitHub when using app auth" )
263
+ flGithubAppPrivateKeyFile := pflag .String ("github-app-private-key-file" ,
264
+ envString ("" , "GITSYNC_GITHUB_APP_PRIVATE_KEY_FILE" ),
265
+ "the file from which the private key for GitHub app auth will be sourced" )
266
+ flGithubAppApplicationID := pflag .Int ("github-app-application-id" ,
267
+ envInt (0 , "GTSYNC_GITHUB_APP_APPLICATION_ID" ),
268
+ "the GitHub app application ID to use for GitHub app auth" )
269
+ flGithubAppInstallationID := pflag .Int ("github-app-installation-id" ,
270
+ envInt (0 , "GITSYNC_GITHUB_APP_INSTALLATION_ID" ),
271
+ "the GitHub app installation ID to use for GitHub app auth" )
272
+
257
273
flGitCmd := pflag .String ("git" ,
258
274
envString ("git" , "GITSYNC_GIT" , "GIT_SYNC_GIT" ),
259
275
"the git command to run (subject to PATH search, mostly for testing)" )
@@ -486,6 +502,7 @@ func main() {
486
502
handleConfigError (log , true , "ERROR: credentials may not be specified in --repo when --username is specified" )
487
503
}
488
504
}
505
+
489
506
} else {
490
507
if * flPassword != "" {
491
508
handleConfigError (log , true , "ERROR: --password may only be specified when --username is specified" )
@@ -495,6 +512,31 @@ func main() {
495
512
}
496
513
}
497
514
515
+ if * flGithubAppPrivateKeyFile != "" {
516
+ if * flGithubAppApplicationID == 0 {
517
+ handleConfigError (log , true , "ERROR: --github-app-application-id must be specified when --github-app-private-key-file is specified" )
518
+ }
519
+ if * flGithubAppInstallationID == 0 {
520
+ handleConfigError (log , true , "ERROR: --github-app-installation-id must be specified when --github-app-private-key-file is specified" )
521
+ }
522
+ if * flUsername != "" {
523
+ handleConfigError (log , true , "ERROR: --username may not be specified when --github-app-private-key-file is specified" )
524
+ }
525
+ if * flPassword != "" {
526
+ handleConfigError (log , true , "ERROR: --password may not be specified when --github-app-private-key-file is specified" )
527
+ }
528
+ if * flPasswordFile != "" {
529
+ handleConfigError (log , true , "ERROR: --password-file may not be specified when --github-app-private-key-file is specified" )
530
+ }
531
+ } else {
532
+ if * flGithubAppApplicationID != 0 {
533
+ handleConfigError (log , true , "ERROR: --github-app-application-id may only be specified when --github-app-private-key-file is specified" )
534
+ }
535
+ if * flGithubAppInstallationID != 0 {
536
+ handleConfigError (log , true , "ERROR: --github-app-installation-id may only be specified when --github-app-private-key-file is specified" )
537
+ }
538
+ }
539
+
498
540
if len (* flCredentials ) > 0 {
499
541
for _ , cred := range * flCredentials {
500
542
if cred .URL == "" {
@@ -780,6 +822,7 @@ func main() {
780
822
return err
781
823
}
782
824
}
825
+
783
826
if * flAskPassURL != "" {
784
827
// When using an auth URL, the credentials can be dynamic, and need
785
828
// to be re-fetched each time.
@@ -789,6 +832,16 @@ func main() {
789
832
}
790
833
metricAskpassCount .WithLabelValues (metricKeySuccess ).Inc ()
791
834
}
835
+
836
+ if * flGithubAppPrivateKeyFile != "" && * flGithubAppInstallationID != 0 && * flGithubAppApplicationID != 0 {
837
+ if git .appTokenExpiry .Before (time .Now ().Add (30 * time .Second )) {
838
+ if err := git .RefreshGitHubAppToken (ctx , * flGithubBaseURL , * flGithubAppPrivateKeyFile , * flGithubAppApplicationID , * flGithubAppInstallationID ); err != nil {
839
+ metricAskpassCount .WithLabelValues (metricKeyError ).Inc ()
840
+ return err
841
+ }
842
+ }
843
+ }
844
+
792
845
return nil
793
846
}
794
847
@@ -1851,6 +1904,83 @@ func (git *repoSync) CallAskPassURL(ctx context.Context) error {
1851
1904
return nil
1852
1905
}
1853
1906
1907
+ // RefreshGitHubAppToken generates a new installation token for a GitHub app and stores it as a credential
1908
+ func (git * repoSync ) RefreshGitHubAppToken (ctx context.Context , githubBaseURL , privateKeyFile string , appID , installationID int ) error {
1909
+ git .log .V (3 ).Info ("refreshing GitHub app token" )
1910
+
1911
+ privateKeyBytes , err := os .ReadFile (privateKeyFile )
1912
+ if err != nil {
1913
+ git .log .Error (err , "can't read private key file" , "file" , privateKeyFile )
1914
+ os .Exit (1 )
1915
+ }
1916
+
1917
+ pkey , err := jwt .ParseRSAPrivateKeyFromPEM ([]byte (privateKeyBytes ))
1918
+ if err != nil {
1919
+ return err
1920
+ }
1921
+
1922
+ now := time .Now ()
1923
+
1924
+ claims := jwt.RegisteredClaims {
1925
+ Issuer : fmt .Sprintf ("%d" , appID ),
1926
+ IssuedAt : jwt .NewNumericDate (now ),
1927
+ ExpiresAt : jwt .NewNumericDate (now .Add (10 * time .Minute )),
1928
+ }
1929
+
1930
+ jwt , err := jwt .NewWithClaims (jwt .SigningMethodRS256 , claims ).SignedString (pkey )
1931
+ if err != nil {
1932
+ return err
1933
+ }
1934
+
1935
+ url , err := url .JoinPath (githubBaseURL , fmt .Sprintf ("app/installations/%d/access_tokens" , installationID ))
1936
+ if err != nil {
1937
+ return err
1938
+ }
1939
+
1940
+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , url , nil )
1941
+ if err != nil {
1942
+ return err
1943
+ }
1944
+
1945
+ req .Header .Set ("Authorization" , "Bearer " + jwt )
1946
+ req .Header .Set ("Accept" , "application/vnd.github+json" )
1947
+
1948
+ resp , err := http .DefaultClient .Do (req )
1949
+ if err != nil {
1950
+ return err
1951
+ }
1952
+ defer func () {
1953
+ _ = resp .Body .Close ()
1954
+ }()
1955
+ if resp .StatusCode != 201 {
1956
+ errMessage , err := io .ReadAll (resp .Body )
1957
+ if err != nil {
1958
+ return fmt .Errorf ("GitHub app installation endpoint returned status %d, failed to read body: %w" , resp .StatusCode , err )
1959
+ }
1960
+ return fmt .Errorf ("GitHub app installation endpoint returned status %d, body: %q" , resp .StatusCode , string (errMessage ))
1961
+ }
1962
+
1963
+ tokenResponse := struct {
1964
+ Token string `json:"token"`
1965
+ ExpiresAt time.Time `json:"expires_at"`
1966
+ }{}
1967
+ if err := json .NewDecoder (resp .Body ).Decode (& tokenResponse ); err != nil {
1968
+ return err
1969
+ }
1970
+
1971
+ git .appTokenExpiry = tokenResponse .ExpiresAt
1972
+
1973
+ // username must be non-empty
1974
+ username := "-"
1975
+ password := tokenResponse .Token
1976
+
1977
+ if err := git .StoreCredentials (ctx , git .repo , username , password ); err != nil {
1978
+ return err
1979
+ }
1980
+
1981
+ return nil
1982
+ }
1983
+
1854
1984
// SetupDefaultGitConfigs configures the global git environment with some
1855
1985
// default settings that we need.
1856
1986
func (git * repoSync ) SetupDefaultGitConfigs (ctx context.Context ) error {
@@ -2209,6 +2339,22 @@ OPTIONS
2209
2339
- off: Disable explicit git garbage collection, which may be a good
2210
2340
fit when also using --one-time.
2211
2341
2342
+ --github-base-url <string>, $GITSYNC_BASE_URL
2343
+ The GitHub base URL to use in GitHub requests when GitHub app
2344
+ authentication is used. If not specified, defaults to
2345
+ https://api.github.com/.
2346
+
2347
+ --github-app-private-key-file <string>, $GITSYNC_APP_PRIVATE_KEY_FILE
2348
+ The file from which the private key to use for GitHub app
2349
+ authentication will be read.
2350
+
2351
+ --github-app-installation-id <int>, $GITSYNC_APP_INSTALLATION_ID
2352
+ The installation ID of the GitHub app used for GitHub app
2353
+ authentication.
2354
+
2355
+ --github-app-application-id <int>, $GITSYNC_APP_APPLICATION_ID
2356
+ The app ID of the GitHub app used for GitHub app authentication.
2357
+
2212
2358
--group-write, $GITSYNC_GROUP_WRITE
2213
2359
Ensure that data written to disk (including the git repo metadata,
2214
2360
checked out files, worktrees, and symlink) are all group writable.
@@ -2427,6 +2573,22 @@ AUTHENTICATION
2427
2573
When --cookie-file (GITSYNC_COOKIE_FILE) is specified, the
2428
2574
associated cookies can contain authentication information.
2429
2575
2576
+ github app
2577
+ When --github-app-private-key-file (GITSYNC_GITHUB_APP_PRIVATE_KEY_FILE),
2578
+ --github-app-application-id (GITSYNC_GITHUB_APP_APPLICATION_ID)
2579
+ and --github-app-installation_id (GITSYNC_GITHUB_APP_INSTALLATION_ID)
2580
+ are specified, GitHub app authentication will be used.
2581
+
2582
+ These credentials are used to request a short-lived token which
2583
+ is used for authentication. The base URL of the GitHub request made
2584
+ to retrieve the token can also be specified via
2585
+ --github-base-url (GITSYNC_GITHUB_BASE_URL), which defaults to
2586
+ https://api.github.com/.
2587
+
2588
+ The GitHub app must have sufficient access to the repository to sync.
2589
+ It should be installed to the repository or organization containing
2590
+ the repository, and given read access (see github docs).
2591
+
2430
2592
HOOKS
2431
2593
2432
2594
Webhooks and exechooks are executed asynchronously from the main git-sync
0 commit comments