Skip to content

Commit be34d6c

Browse files
authored
added nsc cache sccache create-token/setup. (#1705)
1 parent a4c5ea6 commit be34d6c

File tree

8 files changed

+352
-14
lines changed

8 files changed

+352
-14
lines changed

cmd/nsc/cmd/register.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ func RegisterCommands(root *cobra.Command) {
6161
root.AddCommand(cluster.NewBazelCmd())
6262
root.AddCommand(cluster.NewGradleCmd())
6363
root.AddCommand(cluster.NewPantsCmd())
64+
cacheCmd := &cobra.Command{Use: "cache", Short: "Build cache related functionality."}
65+
cacheCmd.AddCommand(cluster.NewSccacheCmd())
66+
cacheCmd.AddCommand(cluster.NewGradleCacheCmd())
67+
cacheCmd.AddCommand(cluster.NewBazelCacheCmd())
68+
cacheCmd.AddCommand(cluster.NewPantsCacheCmd())
69+
root.AddCommand(cacheCmd)
6470
root.AddCommand(cluster.NewArtifactCmd()) // nsc artifact
6571

6672
root.AddCommand(sdk.NewSdkCmd(true))

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ module namespacelabs.dev/foundation
33
go 1.24.4
44

55
require (
6-
buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260126170837-18fd3dc306e3.2
6+
buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260212004106-290ae81f8d6d.2
77
buf.build/gen/go/namespace/cloud/grpc/go v1.6.0-20251207125149-931ce9589bd8.1
8-
buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260126170837-18fd3dc306e3.1
8+
buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260212004106-290ae81f8d6d.1
99
cloud.google.com/go/artifactregistry v1.14.7
1010
cloud.google.com/go/container v1.31.0
1111
connectrpc.com/connect v1.19.1

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260126170837-18fd3dc306e3.2 h1:s5d/VkA9SLniWD8ghO/C2+v4XulT68gTcUKp2OrGaAM=
2-
buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260126170837-18fd3dc306e3.2/go.mod h1:WxkZDjOPKKXYMkN6YpnKGsHJ5BX1fbrhNgz8W8dmAYk=
1+
buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260212004106-290ae81f8d6d.2 h1:XaeFtt6yN8G5q2uYoiTjyshOyai1Q+GzwfEKlxrTzVw=
2+
buf.build/gen/go/namespace/cloud/connectrpc/go v1.19.1-20260212004106-290ae81f8d6d.2/go.mod h1:QvCL7PUDMFotMXVUoWMeRClEEnCbh7S51xHy39mO+H4=
33
buf.build/gen/go/namespace/cloud/grpc/go v1.6.0-20251207125149-931ce9589bd8.1 h1:e2vtAk9xps4Cg9Jom+pQMVzBxVowYR65XbnGmsV4mW0=
44
buf.build/gen/go/namespace/cloud/grpc/go v1.6.0-20251207125149-931ce9589bd8.1/go.mod h1:DtCvgtjGxZqjPjhDeX1sJYZXr0nbinUHqa37KtMDXvE=
5-
buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260126170837-18fd3dc306e3.1 h1:/gUYm1epQiOD/oCH46I1U9F6/m6oOmEfVAt8vhAU0so=
6-
buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260126170837-18fd3dc306e3.1/go.mod h1:Il2wpJNQB40Yj3Rmuhg5xKJPSXaZVwij+Q30d1PNuNY=
5+
buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260212004106-290ae81f8d6d.1 h1:xTgPJaOj5QNRPAA3nxW3fTz01aAOLr/6SG7C4Iqxm54=
6+
buf.build/gen/go/namespace/cloud/protocolbuffers/go v1.36.11-20260212004106-290ae81f8d6d.1/go.mod h1:Il2wpJNQB40Yj3Rmuhg5xKJPSXaZVwij+Q30d1PNuNY=
77
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
88
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
99
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=

internal/cli/cmd/cluster/bazel.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ func NewBazelCmd() *cobra.Command {
4343
return cmd
4444
}
4545

46+
// NewBazelCacheCmd returns a "bazel" command with setup directly
47+
// underneath, for use under "nsc cache bazel setup".
48+
func NewBazelCacheCmd() *cobra.Command {
49+
cmd := &cobra.Command{
50+
Use: "bazel",
51+
Short: "Bazel cache related functionality.",
52+
}
53+
54+
cmd.AddCommand(newSetupCacheCmd())
55+
56+
return cmd
57+
}
58+
4659
func newSetupCacheCmd() *cobra.Command {
4760
var bazelRcPath, output, certPath string
4861
var sendBuildEvents, useAbsoluteCredHelperPath, static bool

internal/cli/cmd/cluster/gradle.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,26 @@ func NewGradleCmd() *cobra.Command {
4949
return cmd
5050
}
5151

52+
// NewGradleCacheCmd returns a "gradle" command with setup/create-token directly
53+
// underneath, for use under "nsc cache gradle {setup|create-token}".
54+
func NewGradleCacheCmd() *cobra.Command {
55+
cmd := &cobra.Command{
56+
Use: "gradle",
57+
Short: "Gradle cache related functionality.",
58+
}
59+
60+
cmd.AddCommand(newSetupGradleCacheCmd())
61+
cmd.AddCommand(newCreateTokenCmd())
62+
63+
return cmd
64+
}
65+
5266
func newSetupGradleCacheCmd() *cobra.Command {
5367
var initGradlePath, output string
5468
var push, user bool
5569
var name, site string
5670
var tokenFile string
71+
var flags *pflag.FlagSet
5772

5873
return fncobra.Cmd(&cobra.Command{
5974
Use: "setup",
@@ -67,15 +82,22 @@ The generated init.gradle can be used with:
6782
gradle --init-script=/path/to/init.gradle build
6883
6984
Or by placing it in ~/.gradle/init.d/ to apply to all builds.`,
70-
}).WithFlags(func(flags *pflag.FlagSet) {
71-
flags.StringVar(&initGradlePath, "init-gradle", "", "If specified, write the init.gradle to this path.")
72-
flags.StringVarP(&output, "output", "o", "plain", "One of plain or json.")
73-
flags.BoolVar(&push, "push", true, "Whether to enable pushing to the cache (default: true).")
74-
flags.StringVar(&name, "name", "default", "A name for the cache.")
75-
flags.StringVar(&site, "site", "", "Site preference (e.g., 'iad', 'fra'). If not set, determined automatically.")
76-
flags.BoolVar(&user, "user", false, "If set, write the init.gradle to ~/.gradle/init.d/namespace.cache.gradle.")
77-
flags.StringVar(&tokenFile, "token", "", "Use the bearer token stored at this location for authentication instead of the default.")
85+
}).WithFlags(func(f *pflag.FlagSet) {
86+
flags = f
87+
f.StringVar(&initGradlePath, "init-gradle", "", "If specified, write the init.gradle to this path.")
88+
f.StringVarP(&output, "output", "o", "plain", "One of plain or json.")
89+
f.BoolVar(&push, "push", true, "Whether to enable pushing to the cache (default: true).")
90+
f.StringVar(&name, "cache_name", "default", "A name for the cache.")
91+
f.StringVar(&name, "name", "default", "Deprecated: use --cache_name instead.")
92+
f.MarkHidden("name")
93+
f.StringVar(&site, "site", "", "Site preference (e.g., 'iad', 'fra'). If not set, determined automatically.")
94+
f.BoolVar(&user, "user", false, "If set, write the init.gradle to ~/.gradle/init.d/namespace.cache.gradle.")
95+
f.StringVar(&tokenFile, "token", "", "Use the bearer token stored at this location for authentication instead of the default.")
7896
}).Do(func(ctx context.Context) error {
97+
if flags.Changed("name") {
98+
fmt.Fprintf(console.Warnings(ctx), "--name is deprecated; use --cache_name\n")
99+
}
100+
79101
if user && initGradlePath != "" {
80102
return fnerrors.New("--user and --init-gradle are mutually exclusive")
81103
}

internal/cli/cmd/cluster/pants.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ func NewPantsCmd() *cobra.Command {
3737
return cmd
3838
}
3939

40+
// NewPantsCacheCmd returns a "pants" command with setup directly
41+
// underneath, for use under "nsc cache pants setup".
42+
func NewPantsCacheCmd() *cobra.Command {
43+
cmd := &cobra.Command{
44+
Use: "pants",
45+
Short: "Pants cache related functionality.",
46+
}
47+
48+
cmd.AddCommand(newSetupPantsCacheCmd())
49+
50+
return cmd
51+
}
52+
4053
func newSetupPantsCacheCmd() *cobra.Command {
4154
var pantsTomlPath string
4255

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright 2022 Namespace Labs Inc; All rights reserved.
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
5+
package cluster
6+
7+
import (
8+
"context"
9+
"encoding/json"
10+
"fmt"
11+
"net/url"
12+
"strings"
13+
"time"
14+
15+
"buf.build/gen/go/namespace/cloud/connectrpc/go/proto/namespace/cloud/integrations/httpcache/v1beta/httpcachev1betaconnect"
16+
iamv1beta "buf.build/gen/go/namespace/cloud/protocolbuffers/go/proto/namespace/cloud/iam/v1beta"
17+
httpcachev1beta "buf.build/gen/go/namespace/cloud/protocolbuffers/go/proto/namespace/cloud/integrations/httpcache/v1beta"
18+
"connectrpc.com/connect"
19+
"github.com/spf13/cobra"
20+
"github.com/spf13/pflag"
21+
"google.golang.org/protobuf/types/known/timestamppb"
22+
"namespacelabs.dev/foundation/internal/cli/fncobra"
23+
"namespacelabs.dev/foundation/internal/console"
24+
"namespacelabs.dev/foundation/internal/console/colors"
25+
"namespacelabs.dev/foundation/internal/fnapi"
26+
"namespacelabs.dev/foundation/internal/fnerrors"
27+
"namespacelabs.dev/go-ids"
28+
"namespacelabs.dev/integrations/api/iam"
29+
"namespacelabs.dev/integrations/auth"
30+
)
31+
32+
func NewSccacheCmd() *cobra.Command {
33+
cmd := &cobra.Command{
34+
Use: "sccache",
35+
Short: "sccache cache related functionality.",
36+
}
37+
38+
cmd.AddCommand(newSetupSccacheCacheCmd())
39+
cmd.AddCommand(newCreateSccacheTokenCmd())
40+
41+
return cmd
42+
}
43+
44+
func newSetupSccacheCacheCmd() *cobra.Command {
45+
var output string
46+
var name, site string
47+
var tokenFile string
48+
49+
return fncobra.Cmd(&cobra.Command{
50+
Use: "setup",
51+
Short: "Set up a remote sccache cache and output the required environment variables.",
52+
Long: `Set up a remote sccache cache and output the required environment variables.
53+
54+
This command provisions a remote build cache and outputs the environment
55+
variables needed to configure sccache to use it.
56+
57+
The output includes:
58+
SCCACHE_WEBDAV_ENDPOINT - The WebDAV endpoint URL
59+
SCCACHE_WEBDAV_KEY_PREFIX - The key prefix path
60+
SCCACHE_WEBDAV_TOKEN - The authentication token`,
61+
}).WithFlags(func(flags *pflag.FlagSet) {
62+
flags.StringVarP(&output, "output", "o", "plain", "One of plain or json.")
63+
flags.StringVar(&name, "cache_name", "", "A name for the cache.")
64+
flags.StringVar(&site, "site", "", "Site preference (e.g., 'iad', 'fra'). If not set, determined automatically.")
65+
flags.StringVar(&tokenFile, "token", "", "Use the bearer token stored at this location for authentication instead of the default.")
66+
}).Do(func(ctx context.Context) error {
67+
var client httpcachev1betaconnect.HttpCacheServiceClient
68+
if tokenFile != "" {
69+
tokenSource, err := loadTokenFromFile(tokenFile)
70+
if err != nil {
71+
return fnerrors.Newf("failed to load token from file: %w", err)
72+
}
73+
74+
client = fnapi.NewHttpCacheServiceClientWithToken(tokenSource)
75+
} else {
76+
cli, err := fnapi.NewHttpCacheServiceClient(ctx)
77+
if err != nil {
78+
return err
79+
}
80+
81+
client = cli
82+
}
83+
84+
req := connect.NewRequest(&httpcachev1beta.EnsureHttpCacheRequest{
85+
Name: name,
86+
Site: site,
87+
})
88+
89+
resp, err := client.EnsureHttpCache(ctx, req)
90+
if err != nil {
91+
return fnerrors.Newf("failed to provision sccache cache: %w", err)
92+
}
93+
94+
if resp.Msg.GetCacheEndpointUrl() == "" {
95+
return fnerrors.Newf("did not receive a valid cache endpoint")
96+
}
97+
98+
var expiresAt *time.Time
99+
if resp.Msg.GetExpiresAt() != nil {
100+
t := resp.Msg.GetExpiresAt().AsTime()
101+
expiresAt = &t
102+
}
103+
104+
// Parse the cache URL into base endpoint and key prefix.
105+
endpoint, keyPrefix, err := splitCacheURL(resp.Msg.GetCacheEndpointUrl())
106+
if err != nil {
107+
return fnerrors.Newf("failed to parse cache endpoint URL: %w", err)
108+
}
109+
110+
token := resp.Msg.GetPassword()
111+
112+
out := sccacheSetup{
113+
Endpoint: endpoint,
114+
KeyPrefix: keyPrefix,
115+
Token: token,
116+
ExpiresAt: expiresAt,
117+
Site: resp.Msg.GetSite(),
118+
}
119+
120+
switch output {
121+
case "json":
122+
d := json.NewEncoder(console.Stdout(ctx))
123+
d.SetIndent("", " ")
124+
if err := d.Encode(out); err != nil {
125+
return fnerrors.InternalError("failed to encode output as JSON: %w", err)
126+
}
127+
128+
default:
129+
if output != "" && output != "plain" {
130+
fmt.Fprintf(console.Warnings(ctx), "unsupported output %q, defaulting to plain\n", output)
131+
}
132+
133+
stdout := console.Stdout(ctx)
134+
fmt.Fprintf(stdout, "SCCACHE_WEBDAV_ENDPOINT=%s\n", out.Endpoint)
135+
fmt.Fprintf(stdout, "SCCACHE_WEBDAV_KEY_PREFIX=%s\n", out.KeyPrefix)
136+
fmt.Fprintf(stdout, "SCCACHE_WEBDAV_TOKEN=%s\n", out.Token)
137+
}
138+
139+
return nil
140+
})
141+
}
142+
143+
func newCreateSccacheTokenCmd() *cobra.Command {
144+
var name string
145+
var expiresIn time.Duration
146+
var tokenFile, scope string
147+
148+
return fncobra.Cmd(&cobra.Command{
149+
Use: "create-token",
150+
Short: "Create a revokable token for accessing the sccache cache.",
151+
}).WithFlags(func(flags *pflag.FlagSet) {
152+
flags.StringVar(&name, "cache_name", "", "Select a cache to grant access to. By default, all caches can be accessed.")
153+
fncobra.DurationVar(flags, &expiresIn, "expires_in", 24*time.Hour, "Duration until the token expires (max 90 days).")
154+
flags.StringVar(&tokenFile, "token", "token.json", "Write token to this file in JSON format.")
155+
flags.StringVar(&scope, "scope", "user", "Set the scope of the generated access token. Valid options: `tenant`, `user`. Tokens with user scope are bound to the tenant membership of the current user.")
156+
}).Do(func(ctx context.Context) error {
157+
httpCacheClient, err := fnapi.NewHttpCacheServiceClient(ctx)
158+
if err != nil {
159+
return err
160+
}
161+
162+
policyResp, err := httpCacheClient.GetAccessPolicy(ctx, connect.NewRequest(&httpcachev1beta.GetAccessPolicyRequest{
163+
Name: name,
164+
}))
165+
if err != nil {
166+
return fnerrors.Newf("failed to get access policy: %w", err)
167+
}
168+
169+
requiredPerms := policyResp.Msg.GetRequiredPermission()
170+
if len(requiredPerms) == 0 {
171+
return fnerrors.New("no permissions required for this cache (unexpected)")
172+
}
173+
174+
tokenSource, err := auth.LoadDefaults()
175+
if err != nil {
176+
return fnerrors.InvocationError("sccache", "failed to get authentication token: %w", err)
177+
}
178+
179+
iamClient, err := iam.NewClient(ctx, tokenSource)
180+
if err != nil {
181+
return fnerrors.InvocationError("sccache", "failed to create IAM client: %w", err)
182+
}
183+
defer iamClient.Close()
184+
185+
suffix := ids.NewRandomBase32ID(4)
186+
tokenName := fmt.Sprintf("sccache-%s-%s", name, suffix)
187+
expiresAt := time.Now().Add(expiresIn)
188+
189+
req := &iamv1beta.CreateRevokableTokenRequest{
190+
Name: tokenName,
191+
Description: fmt.Sprintf("sccache access token for cache %q", name),
192+
ExpiresAt: timestamppb.New(expiresAt),
193+
Access: &iamv1beta.AccessPolicy{
194+
Grants: requiredPerms,
195+
},
196+
}
197+
198+
switch scope {
199+
case "tenant":
200+
req.Scope = iamv1beta.RevokableToken_TENANT_SCOPE
201+
202+
case "user":
203+
req.Scope = iamv1beta.RevokableToken_TENANT_MEMBERSHIP_SCOPE
204+
}
205+
206+
resp, err := iamClient.Tokens.CreateRevokableToken(ctx, req)
207+
if err != nil {
208+
return fnerrors.InvocationError("token", "failed to create token: %w", err)
209+
}
210+
211+
fmt.Fprintf(console.Stdout(ctx), "Token ID: %s\n", resp.Token.GetTokenId())
212+
fmt.Fprintf(console.Stdout(ctx), "Name: %s\n", resp.Token.GetName())
213+
fmt.Fprintf(console.Stdout(ctx), "Expires At: %s\n", expiresAt.Format(time.RFC3339))
214+
215+
if err := writeGradleTokenToFile(tokenFile, resp.BearerToken); err != nil {
216+
return fnerrors.InvocationError("token", "failed to write token to file: %w", err)
217+
}
218+
219+
fmt.Fprintf(console.Stdout(ctx), "You can set up your sccache config with:\n")
220+
221+
style := colors.Ctx(ctx)
222+
cmd := fmt.Sprintf("nsc cache sccache setup --token %s", tokenFile)
223+
if name != "" {
224+
cmd = fmt.Sprintf("%s --name %s", cmd, name)
225+
}
226+
227+
fmt.Fprintf(console.Stdout(ctx), " %s\n", style.Highlight.Apply(cmd))
228+
229+
return nil
230+
})
231+
}
232+
233+
// splitCacheURL parses a cache URL like "https://host:port/some/path/" into
234+
// base endpoint ("https://host:port") and key prefix ("/some/path/").
235+
func splitCacheURL(rawURL string) (string, string, error) {
236+
u, err := url.Parse(rawURL)
237+
if err != nil {
238+
return "", "", fmt.Errorf("invalid URL %q: %w", rawURL, err)
239+
}
240+
241+
endpoint := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
242+
keyPrefix := strings.TrimPrefix(u.Path, "/")
243+
244+
return endpoint, keyPrefix, nil
245+
}
246+
247+
type sccacheSetup struct {
248+
Endpoint string `json:"endpoint,omitempty"`
249+
KeyPrefix string `json:"key_prefix,omitempty"`
250+
Token string `json:"token,omitempty"`
251+
ExpiresAt *time.Time `json:"expires_at,omitempty"`
252+
Site string `json:"site,omitempty"`
253+
}

0 commit comments

Comments
 (0)