Skip to content

Commit 2e9723e

Browse files
committed
feat(cmd/rofl): Add support for machine logs
1 parent 750214c commit 2e9723e

8 files changed

Lines changed: 389 additions & 0 deletions

File tree

build/rofl/scheduler/auth.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package scheduler
2+
3+
import (
4+
"encoding/base64"
5+
"time"
6+
7+
"github.com/oasisprotocol/oasis-core/go/common/cbor"
8+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/crypto/signature"
9+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/types"
10+
)
11+
12+
// StdAuthContextBase is the base authentication context.
13+
const StdAuthContextBase = "rofl-scheduler/auth: v1"
14+
15+
// AuthLoginRequest is the request to login.
16+
type AuthLoginRequest struct {
17+
Method string `json:"method"`
18+
Data any `json:"data"`
19+
}
20+
21+
// AuthLoginResponse is the response from the login request.
22+
type AuthLoginResponse struct {
23+
Token string `json:"token"`
24+
Expiry uint64 `json:"expiry"`
25+
}
26+
27+
// StdAuthLoginRequest is the body for the standard authentication method.
28+
type StdAuthLoginRequest struct {
29+
Body string `json:"body"`
30+
Signature string `json:"signature"`
31+
}
32+
33+
// StdAuthBody is the standard authentication body.
34+
type StdAuthBody struct {
35+
Version uint16 `json:"v"`
36+
Domain string `json:"domain"`
37+
Provider types.Address `json:"provider"`
38+
Signer types.PublicKey `json:"signer"`
39+
Nonce string `json:"nonce"`
40+
NotBefore uint64 `json:"not_before"`
41+
NotAfter uint64 `json:"not_after"`
42+
}
43+
44+
// SignLogin creates a new login request for the given provider and signs it.
45+
func SignLogin(sigCtx signature.Context, signer signature.Signer, domain string, provider types.Address) (*AuthLoginRequest, error) {
46+
notBefore := time.Now().Add(-10 * time.Second) // Allow for some time drift.
47+
notAfter := time.Now().Add(30 * time.Second) // Short expiry, just needed for login.
48+
49+
body := StdAuthBody{
50+
Version: 1,
51+
Domain: domain,
52+
Provider: provider,
53+
Signer: types.PublicKey{PublicKey: signer.Public()},
54+
Nonce: "",
55+
NotBefore: uint64(notBefore.Unix()),
56+
NotAfter: uint64(notAfter.Unix()),
57+
}
58+
rawBody := cbor.Marshal(body)
59+
sig, err := signer.ContextSign(sigCtx, rawBody)
60+
if err != nil {
61+
return nil, err
62+
}
63+
64+
rq := &AuthLoginRequest{
65+
Method: "std",
66+
Data: StdAuthLoginRequest{
67+
Body: base64.StdEncoding.EncodeToString(rawBody),
68+
Signature: base64.StdEncoding.EncodeToString(sig),
69+
},
70+
}
71+
return rq, nil
72+
}

build/rofl/scheduler/client.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package scheduler
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"sync"
11+
"time"
12+
13+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl"
14+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/roflmarket"
15+
)
16+
17+
const (
18+
apiUriAuthLogin = "/rofl-scheduler/v1/auth/login"
19+
apiUriLogsGet = "/rofl-scheduler/v1/logs/get"
20+
)
21+
22+
// Client is a ROFL scheduler API endpoint client.
23+
type Client struct {
24+
mu sync.Mutex
25+
26+
baseUri *url.URL
27+
hc *http.Client
28+
29+
authToken string
30+
authExpiry time.Time
31+
}
32+
33+
// NewClient creates a new ROFL scheduler API endpoint client.
34+
func NewClient(dsc *rofl.Registration) (*Client, error) {
35+
baseUriRaw, ok := dsc.Metadata[MetadataKeySchedulerAPI]
36+
if !ok {
37+
return nil, fmt.Errorf("scheduler does not publish an API endpoint")
38+
}
39+
baseUri, err := url.Parse(baseUriRaw)
40+
if err != nil {
41+
return nil, fmt.Errorf("malformed API endpoint: %w", err)
42+
}
43+
44+
hc, err := NewHTTPClient(dsc)
45+
if err != nil {
46+
return nil, err
47+
}
48+
49+
return &Client{
50+
baseUri: baseUri,
51+
hc: hc,
52+
}, nil
53+
}
54+
55+
// Host is the hostname.
56+
func (c *Client) Host() string {
57+
return c.baseUri.Host
58+
}
59+
60+
// Login authenticates to the scheduler using the given login request.
61+
func (c *Client) Login(req *AuthLoginRequest) error {
62+
c.mu.Lock()
63+
authToken := c.authToken
64+
authExpiry := c.authExpiry
65+
c.mu.Unlock()
66+
67+
if authToken != "" && time.Now().Before(authExpiry) {
68+
return nil
69+
}
70+
71+
var rsp AuthLoginResponse
72+
if err := c.post(apiUriAuthLogin, req, &rsp); err != nil {
73+
return err
74+
}
75+
76+
c.mu.Lock()
77+
defer c.mu.Unlock()
78+
79+
c.authToken = rsp.Token
80+
c.authExpiry = time.Unix(int64(rsp.Expiry), 0)
81+
return nil
82+
}
83+
84+
// LogsGet fetches logs for the given machine.
85+
func (c *Client) LogsGet(machineID roflmarket.InstanceID, since time.Time) ([]string, error) {
86+
hexInstanceID, err := machineID.MarshalText()
87+
if err != nil {
88+
return nil, err
89+
}
90+
91+
req := LogsGetRequest{
92+
InstanceID: string(hexInstanceID),
93+
Since: uint64(since.Unix()),
94+
}
95+
var rsp LogsGetResponse
96+
if err = c.post(apiUriLogsGet, req, &rsp); err != nil {
97+
return nil, err
98+
}
99+
return rsp.Logs, nil
100+
}
101+
102+
func (c *Client) post(path string, request, response any) error {
103+
encRequest, err := json.Marshal(request)
104+
if err != nil {
105+
return err
106+
}
107+
108+
url := c.baseUri.JoinPath(path).String()
109+
rq, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(encRequest))
110+
if err != nil {
111+
return err
112+
}
113+
114+
rq.Header.Add("Content-Type", "application/json")
115+
116+
c.mu.Lock()
117+
if c.authToken != "" {
118+
rq.Header.Add("Authorization", "Bearer "+c.authToken)
119+
}
120+
c.mu.Unlock()
121+
122+
rsp, err := c.hc.Do(rq)
123+
if err != nil {
124+
return err
125+
}
126+
defer rsp.Body.Close()
127+
128+
data, err := io.ReadAll(rsp.Body)
129+
if err != nil {
130+
return err
131+
}
132+
switch rsp.StatusCode {
133+
case http.StatusOK:
134+
return json.Unmarshal(data, response)
135+
default:
136+
return fmt.Errorf("unexpected response from server: %s (%s)", rsp.Status, string(data))
137+
}
138+
}

build/rofl/scheduler/commands.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,20 @@ type TerminateRequest struct {
4040
// WipeStorage is a flag indicating whether persistent storage should be wiped.
4141
WipeStorage bool `json:"wipe_storage"`
4242
}
43+
44+
// LogsGetRequest is a request to get logs.
45+
type LogsGetRequest struct {
46+
// InstanceID is the instance identifier.
47+
InstanceID string `json:"instance_id"`
48+
// ComponentID is the optional component identifier.
49+
ComponentID string `json:"component_id,omitempty"`
50+
// Since is an optional UNIX timestamp to filter log entries by. Only entries with higher
51+
// timestamps will be returned.
52+
Since uint64 `json:"since,omitempty"`
53+
}
54+
55+
// LogsGetResponse is the response from the LogsGet request.
56+
type LogsGetResponse struct {
57+
// Logs are the resulting log lines.
58+
Logs []string `json:"logs"`
59+
}

build/rofl/scheduler/metadata.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package scheduler
2+
3+
const (
4+
// MetadataKeyError is the name of the metadata key that stores the error message.
5+
MetadataKeyError = "net.oasis.error"
6+
// MetadataKeySchedulerRAK is the name of the metadata key taht stores the scheduler RAK.
7+
MetadataKeySchedulerRAK = "net.oasis.scheduler.rak"
8+
// MetadataKeyTLSPk is the name of the metadata key that stores the TLS public key.
9+
MetadataKeyTLSPk = "net.oasis.tls.pk"
10+
// MetadataKeySchedulerAPI is the name of the metadata key that stores the API endpoint address.
11+
MetadataKeySchedulerAPI = "net.oasis.scheduler.api"
12+
)

build/rofl/scheduler/tls.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package scheduler
2+
3+
import (
4+
"bytes"
5+
"crypto/tls"
6+
"crypto/x509"
7+
"encoding/base64"
8+
"fmt"
9+
"net/http"
10+
11+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/modules/rofl"
12+
)
13+
14+
// NewHTTPClient creates an HTTP client to communicate with the given scheduler.
15+
func NewHTTPClient(dsc *rofl.Registration) (*http.Client, error) {
16+
schedulerTLSPk, ok := dsc.Metadata[MetadataKeyTLSPk]
17+
if !ok {
18+
return nil, fmt.Errorf("scheduler does not publish its TLS public key")
19+
}
20+
expectedSubjectPublicKeyInfo, err := base64.StdEncoding.DecodeString(schedulerTLSPk)
21+
if err != nil {
22+
return nil, fmt.Errorf("malformed scheduler TLS public key: %w", err)
23+
}
24+
25+
transport := &http.Transport{
26+
TLSClientConfig: &tls.Config{
27+
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
28+
if len(rawCerts) == 0 {
29+
return fmt.Errorf("server did not send a certificate")
30+
}
31+
32+
cert, err := x509.ParseCertificate(rawCerts[0])
33+
if err != nil {
34+
return fmt.Errorf("bad X509 certificate: %w", err)
35+
}
36+
37+
if !bytes.Equal(cert.RawSubjectPublicKeyInfo, expectedSubjectPublicKeyInfo) {
38+
return fmt.Errorf("server certificate public key does not match expected value")
39+
}
40+
return nil
41+
},
42+
},
43+
}
44+
client := &http.Client{
45+
Transport: transport,
46+
}
47+
return client, nil
48+
}

cmd/rofl/machine/logs.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package machine
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/spf13/cobra"
9+
flag "github.com/spf13/pflag"
10+
11+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/client"
12+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/connection"
13+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/crypto/signature"
14+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/crypto/signature/ed25519"
15+
"github.com/oasisprotocol/oasis-sdk/client-sdk/go/types"
16+
17+
buildRofl "github.com/oasisprotocol/cli/build/rofl"
18+
"github.com/oasisprotocol/cli/build/rofl/scheduler"
19+
"github.com/oasisprotocol/cli/cmd/common"
20+
roflCommon "github.com/oasisprotocol/cli/cmd/rofl/common"
21+
cliConfig "github.com/oasisprotocol/cli/config"
22+
)
23+
24+
var logsCmd = &cobra.Command{
25+
Use: "logs [<machine-name>]",
26+
Short: "Show logs from the given machine",
27+
Args: cobra.MaximumNArgs(1),
28+
Run: func(_ *cobra.Command, args []string) {
29+
cfg := cliConfig.Global()
30+
npa := common.GetNPASelection(cfg)
31+
32+
_, deployment := roflCommon.LoadManifestAndSetNPA(cfg, npa, deploymentName, &roflCommon.ManifestOptions{
33+
NeedAppID: true,
34+
NeedAdmin: false,
35+
})
36+
37+
machine, _, machineID := resolveMachine(args, deployment)
38+
39+
// Establish connection with the target network.
40+
ctx := context.Background()
41+
conn, err := connection.Connect(ctx, npa.Network)
42+
cobra.CheckErr(err)
43+
44+
// Resolve provider address.
45+
providerAddr, _, err := common.ResolveLocalAccountOrAddress(npa.Network, machine.Provider)
46+
if err != nil {
47+
cobra.CheckErr(fmt.Sprintf("Invalid provider address: %s", err))
48+
}
49+
50+
providerDsc, err := conn.Runtime(npa.ParaTime).ROFLMarket.Provider(ctx, client.RoundLatest, *providerAddr)
51+
cobra.CheckErr(err)
52+
53+
insDsc, err := conn.Runtime(npa.ParaTime).ROFLMarket.Instance(ctx, client.RoundLatest, *providerAddr, machineID)
54+
cobra.CheckErr(err)
55+
56+
schedulerRAKRaw, ok := insDsc.Metadata[scheduler.MetadataKeySchedulerRAK]
57+
if !ok {
58+
cobra.CheckErr(fmt.Sprintf("Machine is missing scheduler RAK metadata."))
59+
}
60+
var schedulerRAK ed25519.PublicKey
61+
if err := schedulerRAK.UnmarshalText([]byte(schedulerRAKRaw)); err != nil {
62+
cobra.CheckErr(fmt.Sprintf("Malformed scheduler RAK metadata: %s", err))
63+
}
64+
pk := types.PublicKey{PublicKey: schedulerRAK}
65+
66+
schedulerDsc, err := conn.Runtime(npa.ParaTime).ROFL.AppInstance(ctx, client.RoundLatest, providerDsc.SchedulerApp, pk)
67+
cobra.CheckErr(err)
68+
69+
client, err := scheduler.NewClient(schedulerDsc)
70+
cobra.CheckErr(err)
71+
72+
// TODO: Cache authentication token so we don't need to re-authenticate.
73+
acc := common.LoadAccount(cfg, npa.AccountName)
74+
75+
sigCtx := &signature.RichContext{
76+
RuntimeID: npa.ParaTime.Namespace(),
77+
ChainContext: npa.Network.ChainContext,
78+
Base: []byte(scheduler.StdAuthContextBase),
79+
}
80+
authRequest, err := scheduler.SignLogin(sigCtx, acc.Signer(), client.Host(), *providerAddr)
81+
cobra.CheckErr(err)
82+
83+
err = client.Login(authRequest)
84+
cobra.CheckErr(err)
85+
86+
logs, err := client.LogsGet(machineID, time.Time{})
87+
cobra.CheckErr(err)
88+
for _, line := range logs {
89+
fmt.Println(line)
90+
}
91+
},
92+
}
93+
94+
func init() {
95+
deploymentFlags := flag.NewFlagSet("", flag.ContinueOnError)
96+
deploymentFlags.StringVar(&deploymentName, "deployment", buildRofl.DefaultDeploymentName, "deployment name")
97+
98+
logsCmd.Flags().AddFlagSet(deploymentFlags)
99+
}

0 commit comments

Comments
 (0)