Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions v1/providers/sfcomputev2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# SFCompute V2 networking

Configurable ingress is optional. Existing credentials continue using the SSH
proxy and do not create firewalls. To request public IPv4 for new instances:

```go
credential := v2.NewSFCCredentialV2(refID, apiKey, organization, workspace)
credential.EnableConfigurableFirewall = true
```

The equivalent JSON field is `"enable_configurable_firewall": true`. Enable it
only after the SFCompute migration, API, and capacity reconciler have finished
deploying. Roll out this Brev integration afterward. The pool must advertise
`public_ipv4_skus`; the adapter selects only those SKUs. Missing support returns
an error before creating an instance or firewall.

The API key needs firewall read, create, write, and delete permissions in the
pool's workspace, in addition to the existing instance and pool permissions.
Each new instance gets its own firewall. TCP ports 22 and 2222 remain open for
SSH; additional port ranges apply to TCP and UDP. Sources must be public IPv4
CIDRs or `0.0.0.0/0`. Narrowing outbound traffic is unsupported. SFCompute's
firewall quotas apply, including the limit of 99 custom firewalls per workspace.

`GetInstance` and `ListInstances` return the public IP, SSH port 22, and stable
ingress rule IDs for revocation. Firewall edits preserve unrelated rules and
retry concurrent changes through `/integrations/brev/v1/firewalls`. Rule updates
require the version returned by that integration endpoint. Termination deletes
the attached firewall only when its ID matches the instance's Brev ownership tag.
A cleanup failure is returned to the caller; retrying termination retries
firewall deletion. Cleanup uses a separate bounded context
so cancellation of a create request does not cancel its firewall cleanup.

Turning the credential option off affects new instances. Existing public-IP
instances remain accessible and their firewalls can still be managed. Existing
SSH-proxy instances cannot acquire public networking through a rule update.
55 changes: 40 additions & 15 deletions v1/providers/sfcomputev2/api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type createInstanceRequest struct {
CloudInitUserData *string `json:"cloud_init_user_data,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
PreviewEnableInfiniband bool `json:"_preview_enable_infiniband"`
EnablePublicIPv4 bool `json:"enable_public_ipv4,omitempty"`
Firewall string `json:"firewall,omitempty"`
}

type instanceStatus string
Expand All @@ -47,12 +49,15 @@ type instanceSKUSummary struct {
}

type instanceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Status instanceStatus `json:"status"`
InstanceSKU *instanceSKUSummary `json:"instance_sku"`
CreatedAt int64 `json:"created_at"`
Tags map[string]string `json:"tags"`
ID string `json:"id"`
Name string `json:"name"`
Status instanceStatus `json:"status"`
InstanceSKU *instanceSKUSummary `json:"instance_sku"`
CreatedAt int64 `json:"created_at"`
Tags map[string]string `json:"tags"`
EnablePublicIPv4 bool `json:"enable_public_ipv4"`
Firewall string `json:"firewall"`
PublicIP string `json:"public_ip"`
}

type listInstancesResponse struct {
Expand Down Expand Up @@ -92,6 +97,7 @@ type allocationSchedule struct {

type poolResponse struct {
AllocationSchedule allocationSchedule `json:"allocation_schedule"`
PublicIPv4SKUs *[]string `json:"public_ipv4_skus,omitempty"`
}

type apiError struct {
Expand Down Expand Up @@ -153,8 +159,16 @@ func (c *apiClient) listInstances(ctx context.Context, workspace, pool string) (
}

func (c *apiClient) terminateInstance(ctx context.Context, id string) error {
_, err := c.terminateInstanceWithResponse(ctx, id)
return err
}

func (c *apiClient) terminateInstanceWithResponse(ctx context.Context, id string) (*instanceResponse, error) {
var response instanceResponse
return c.do(ctx, http.MethodPost, "/instances/"+url.PathEscape(id)+"/terminate", nil, nil, &response)
if err := c.do(ctx, http.MethodPost, "/instances/"+url.PathEscape(id)+"/terminate", nil, nil, &response); err != nil {
return nil, err
}
return &response, nil
}

func (c *apiClient) getSSHInfo(ctx context.Context, id string) (*instanceSSHInfo, error) {
Expand All @@ -181,46 +195,57 @@ func (c *apiClient) do(
requestBody any,
responseBody any,
) error {
_, err := c.doRequest(ctx, method, brevAPIPath+path, query, requestBody, responseBody, nil)
return err
}

func (c *apiClient) doRequest(
ctx context.Context, method, path string, query url.Values,
requestBody, responseBody any, headers http.Header,
) (http.Header, error) {
var body io.Reader
if requestBody != nil {
encoded, err := json.Marshal(requestBody)
if err != nil {
return err
return nil, err
}
body = bytes.NewReader(encoded)
}

request, err := http.NewRequestWithContext(
ctx,
method,
strings.TrimRight(c.baseURL, "/")+brevAPIPath+path,
strings.TrimRight(c.baseURL, "/")+path,
body,
)
if err != nil {
return err
return nil, err
}
request.URL.RawQuery = query.Encode()
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+c.apiKey)
for key, values := range headers {
request.Header[key] = values
}
if requestBody != nil {
request.Header.Set("Content-Type", "application/json")
}

response, err := c.httpClient.Do(request)
if err != nil {
return err
return nil, err
}
defer func() { _ = response.Body.Close() }()

responseBytes, err := io.ReadAll(response.Body)
if err != nil {
return err
return nil, err
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return &apiError{statusCode: response.StatusCode, body: string(responseBytes)}
return response.Header, &apiError{statusCode: response.StatusCode, body: string(responseBytes)}
}
if responseBody == nil || len(responseBytes) == 0 {
return nil
return response.Header, nil
}
return json.Unmarshal(responseBytes, responseBody)
return response.Header, json.Unmarshal(responseBytes, responseBody)
}
10 changes: 5 additions & 5 deletions v1/providers/sfcomputev2/api_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestAPIClientUsesBrevContract(t *testing.T) {
require.Equal(t, "Bearer api-key", request.Header.Get("Authorization"))

switch request.Method + " " + request.URL.Path {
case "POST /integrations/brev/v1/instances":
case createInstanceRoute:
var body map[string]any
require.NoError(t, json.NewDecoder(request.Body).Decode(&body))
require.Equal(t, "sfc:pool:account:workspace:default", body["pool"])
Expand All @@ -29,7 +29,7 @@ func TestAPIClientUsesBrevContract(t *testing.T) {
require.Equal(t, "brev-ref", tags[tagKeyRefID])
require.Equal(t, false, body["_preview_enable_infiniband"])
writeJSON(t, writer, instanceResponse{ID: "inst_created", Status: instanceStatusAwaitingAllocation})
case "GET /integrations/brev/v1/instances":
case listInstancesRoute:
require.Equal(t, "sfc:workspace:account:workspace", request.URL.Query().Get("workspace"))
require.Equal(t, []string{"sfc:pool:account:workspace:default"}, request.URL.Query()["pool"])
require.Equal(t, "200", request.URL.Query().Get("limit"))
Expand All @@ -43,13 +43,13 @@ func TestAPIClientUsesBrevContract(t *testing.T) {
}
require.Equal(t, "next-page", request.URL.Query().Get("starting_after"))
writeJSON(t, writer, listInstancesResponse{Data: []instanceResponse{{ID: "inst_listed_2"}}})
case "GET /integrations/brev/v1/instances/inst_test":
case getTestInstanceRoute:
writeJSON(t, writer, instanceResponse{ID: "inst_test", Status: instanceStatusRunning})
case "GET /integrations/brev/v1/instances/inst_test/ssh":
writeJSON(t, writer, instanceSSHInfo{Hostname: "192.0.2.1", Port: 22})
case "POST /integrations/brev/v1/instances/inst_test/terminate":
case terminateTestInstanceRoute:
writeJSON(t, writer, instanceResponse{ID: "inst_test", Status: instanceStatusTerminated})
case "GET /integrations/brev/v1/pools/sfc:pool:account:workspace:default":
case getTestPoolRoute:
writeJSON(t, writer, poolResponse{AllocationSchedule: allocationSchedule{
ByInstanceSKU: map[string][]scheduleEntry{"is_sku": {{StartAt: 0, NodeCount: 1}}},
}})
Expand Down
4 changes: 2 additions & 2 deletions v1/providers/sfcomputev2/brev_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import "fmt"
const (
defaultSSHUsername = "ubuntu"

// Internal tag keys written to every SFCompute V2 instance. These are stripped from
// v1.Instance.Tags on read so they don't surface as user-facing tags.
// Provider metadata is stripped from v1.Instance.Tags on read.
tagKeyCloudCredRefID = "brev-cloud-cred-ref-id" //nolint:gosec // not a secret
tagKeyRefID = "brev-ref-id"
tagKeyFirewallID = "brev-firewall-id"

// Brev environment config for SFCompute V2.
brevDefaultImageResourcePath = "sfc:image:sfcompute:public:ubuntu-24.04.4-cuda-12.8"
Expand Down
26 changes: 22 additions & 4 deletions v1/providers/sfcomputev2/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,28 @@ func getSFCCapabilitiesV2() v1.Capabilities {
}
}

func (c *SFCClientV2) GetCapabilities(_ context.Context) (v1.Capabilities, error) {
return getSFCCapabilitiesV2(), nil
func (c *SFCClientV2) GetCapabilities(ctx context.Context) (v1.Capabilities, error) {
capabilities := getSFCCapabilitiesV2()
if !c.enableConfigurableFirewall {
return capabilities, nil
}
pool, err := c.client.getPool(ctx, c.GetDefaultPoolResourcePath())
if err != nil {
return nil, err
}
if pool.PublicIPv4SKUs != nil && len(*pool.PublicIPv4SKUs) > 0 {
capabilities = append(capabilities, v1.CapabilityModifyFirewall)
}
return capabilities, nil
}

func (c *SFCCredentialV2) GetCapabilities(_ context.Context) (v1.Capabilities, error) {
return getSFCCapabilitiesV2(), nil
func (c *SFCCredentialV2) GetCapabilities(ctx context.Context) (v1.Capabilities, error) {
if !c.EnableConfigurableFirewall {
return getSFCCapabilitiesV2(), nil
}
client, err := c.MakeClient(ctx, "")
if err != nil {
return nil, err
}
return client.GetCapabilities(ctx)
}
35 changes: 19 additions & 16 deletions v1/providers/sfcomputev2/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ const CloudProviderID = "sfcompute"

// SFCCredentialV2 holds authentication details for a Brev-managed SFCompute V2 account.
type SFCCredentialV2 struct {
RefID string
APIKey string `json:"api_key"`
Organization string `json:"organization"`
Workspace string `json:"workspace"`
RefID string
APIKey string `json:"api_key"`
Organization string `json:"organization"`
Workspace string `json:"workspace"`
EnableConfigurableFirewall bool `json:"enable_configurable_firewall,omitempty"`
}

var _ v1.CloudCredential = &SFCCredentialV2{}
Expand Down Expand Up @@ -45,12 +46,13 @@ func (c *SFCCredentialV2) GetTenantID() (string, error) {

type SFCClientV2 struct {
v1.NotImplCloudClient
refID string
organization string
workspace string
location string
client *apiClient
logger v1.Logger
refID string
organization string
workspace string
location string
client *apiClient
logger v1.Logger
enableConfigurableFirewall bool
}

var _ v1.CloudClient = &SFCClientV2{}
Expand All @@ -65,12 +67,13 @@ func WithLogger(logger v1.Logger) SFCClientV2Option {

func (c *SFCCredentialV2) MakeClientWithOptions(_ context.Context, location string, opts ...SFCClientV2Option) (v1.CloudClient, error) {
sfcClient := &SFCClientV2{
refID: c.RefID,
organization: c.Organization,
workspace: c.Workspace,
location: location,
client: newAPIClient(c.APIKey),
logger: &v1.NoopLogger{},
refID: c.RefID,
organization: c.Organization,
workspace: c.Workspace,
location: location,
client: newAPIClient(c.APIKey),
logger: &v1.NoopLogger{},
enableConfigurableFirewall: c.EnableConfigurableFirewall,
}

for _, opt := range opts {
Expand Down
Loading