|
| 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