Skip to content

Commit c4fc9c0

Browse files
committed
Add client functions to allow integrations within other CLIs.
This is a simplification of how the docker engine implements this feature, but it will be ported there once this is merged. Signed-off-by: David Calavera <[email protected]>
1 parent 00703eb commit c4fc9c0

File tree

13 files changed

+369
-24
lines changed

13 files changed

+369
-24
lines changed

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ $ make osxkeychain
2727

2828
## Usage
2929

30+
### With the Docker Engine
31+
3032
Set the `credsStore` option in your `.docker/config.json` file with the suffix of the program you want to use. For instance, set it to `osxkeychain` if you want to use `docker-credential-osxkeychain`.
3133

3234
```json
@@ -35,11 +37,24 @@ Set the `credsStore` option in your `.docker/config.json` file with the suffix o
3537
}
3638
```
3739

40+
### With other command line applications
41+
42+
The sub-package [client](https://godoc.org/github.com/docker/docker-credential-helpers/client) includes
43+
functions to call external programs from your own command line applications.
44+
45+
There are three things you need to know if you need to interact with a helper:
46+
47+
1. The name of the program to execute, for instance `docker-credential-osxkeychain`.
48+
2. The server address to identify the credentials, for instance `https://example.com`.
49+
3. The username and secret to store, when you want to store credentials.
50+
51+
You can see examples of each function in the [client](https://godoc.org/github.com/docker/docker-credential-helpers/client) documentation.
52+
3853
### Available programs
3954

4055
1. osxkeychain: Provides a helper to use the OS X keychain as credentials store.
41-
1. secretservice: Provides a helper to use the D-Bus secret service as credentials store.
42-
2. wincred: Provides a helper to use Windows credentials manager as store.
56+
2. secretservice: Provides a helper to use the D-Bus secret service as credentials store.
57+
3. wincred: Provides a helper to use Windows credentials manager as store.
4358

4459
## Development
4560

client/client.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package client
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/docker/docker-credential-helpers/credentials"
10+
)
11+
12+
// Store uses an external program to save credentials.
13+
func Store(program ProgramFunc, credentials *credentials.Credentials) error {
14+
cmd := program("store")
15+
16+
buffer := new(bytes.Buffer)
17+
if err := json.NewEncoder(buffer).Encode(credentials); err != nil {
18+
return err
19+
}
20+
cmd.Input(buffer)
21+
22+
out, err := cmd.Output()
23+
if err != nil {
24+
t := strings.TrimSpace(string(out))
25+
return fmt.Errorf("error storing credentials - err: %v, out: `%s`", err, t)
26+
}
27+
28+
return nil
29+
}
30+
31+
// Get executes an external program to get the credentials from a native store.
32+
func Get(program ProgramFunc, serverURL string) (*credentials.Credentials, error) {
33+
cmd := program("get")
34+
cmd.Input(strings.NewReader(serverURL))
35+
36+
out, err := cmd.Output()
37+
if err != nil {
38+
t := strings.TrimSpace(string(out))
39+
40+
if credentials.IsErrCredentialsNotFoundMessage(t) {
41+
return nil, credentials.NewErrCredentialsNotFound()
42+
}
43+
44+
return nil, fmt.Errorf("error getting credentials - err: %v, out: `%s`", err, t)
45+
}
46+
47+
resp := &credentials.Credentials{
48+
ServerURL: serverURL,
49+
}
50+
51+
if err := json.NewDecoder(bytes.NewReader(out)).Decode(resp); err != nil {
52+
return nil, err
53+
}
54+
55+
return resp, nil
56+
}
57+
58+
// Erase executes a program to remove the server credentails from the native store.
59+
func Erase(program ProgramFunc, serverURL string) error {
60+
cmd := program("erase")
61+
cmd.Input(strings.NewReader(serverURL))
62+
63+
out, err := cmd.Output()
64+
if err != nil {
65+
t := strings.TrimSpace(string(out))
66+
return fmt.Errorf("error erasing credentials - err: %v, out: `%s`", err, t)
67+
}
68+
69+
return nil
70+
}

client/client_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package client
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"io/ioutil"
8+
"strings"
9+
"testing"
10+
11+
"github.com/docker/docker-credential-helpers/credentials"
12+
)
13+
14+
const (
15+
validServerAddress = "https://index.docker.io/v1"
16+
validServerAddress2 = "https://example.com:5002"
17+
invalidServerAddress = "https://foobar.example.com"
18+
missingCredsAddress = "https://missing.docker.io/v1"
19+
)
20+
21+
var errProgramExited = fmt.Errorf("exited 1")
22+
23+
// mockProgram simulates interactions between the docker client and a remote
24+
// credentials helper.
25+
// Unit tests inject this mocked command into the remote to control execution.
26+
type mockProgram struct {
27+
arg string
28+
input io.Reader
29+
}
30+
31+
// Output returns responses from the remote credentials helper.
32+
// It mocks those responses based in the input in the mock.
33+
func (m *mockProgram) Output() ([]byte, error) {
34+
in, err := ioutil.ReadAll(m.input)
35+
if err != nil {
36+
return nil, err
37+
}
38+
inS := string(in)
39+
40+
switch m.arg {
41+
case "erase":
42+
switch inS {
43+
case validServerAddress:
44+
return nil, nil
45+
default:
46+
return []byte("program failed"), errProgramExited
47+
}
48+
case "get":
49+
switch inS {
50+
case validServerAddress:
51+
return []byte(`{"Username": "foo", "Secret": "bar"}`), nil
52+
case validServerAddress2:
53+
return []byte(`{"Username": "<token>", "Secret": "abcd1234"}`), nil
54+
case missingCredsAddress:
55+
return []byte(credentials.NewErrCredentialsNotFound().Error()), errProgramExited
56+
case invalidServerAddress:
57+
return []byte("program failed"), errProgramExited
58+
}
59+
case "store":
60+
var c credentials.Credentials
61+
err := json.NewDecoder(strings.NewReader(inS)).Decode(&c)
62+
if err != nil {
63+
return []byte("error storing credentials"), errProgramExited
64+
}
65+
switch c.ServerURL {
66+
case validServerAddress:
67+
return nil, nil
68+
case validServerAddress2:
69+
return nil, nil
70+
default:
71+
return []byte("error storing credentials"), errProgramExited
72+
}
73+
}
74+
75+
return []byte(fmt.Sprintf("unknown argument %q with %q", m.arg, inS)), errProgramExited
76+
}
77+
78+
// Input sets the input to send to a remote credentials helper.
79+
func (m *mockProgram) Input(in io.Reader) {
80+
m.input = in
81+
}
82+
83+
func mockProgramFn(args ...string) Program {
84+
return &mockProgram{
85+
arg: args[0],
86+
}
87+
}
88+
89+
func ExampleStore() {
90+
p := NewShellProgramFunc("docker-credential-secretservice")
91+
92+
c := &credentials.Credentials{
93+
ServerURL: "https://example.com",
94+
Username: "calavera",
95+
Secret: "my super secret token",
96+
}
97+
98+
if err := Store(p, c); err != nil {
99+
fmt.Println(err)
100+
}
101+
}
102+
103+
func TestStore(t *testing.T) {
104+
valid := []credentials.Credentials{
105+
{validServerAddress, "foo", "bar"},
106+
{validServerAddress2, "<token>", "abcd1234"},
107+
}
108+
109+
for _, v := range valid {
110+
if err := Store(mockProgramFn, &v); err != nil {
111+
t.Fatal(err)
112+
}
113+
}
114+
115+
invalid := []credentials.Credentials{
116+
{invalidServerAddress, "foo", "bar"},
117+
}
118+
119+
for _, v := range invalid {
120+
if err := Store(mockProgramFn, &v); err == nil {
121+
t.Fatalf("Expected error for server %s, got nil", v.ServerURL)
122+
}
123+
}
124+
}
125+
126+
func ExampleGet() {
127+
p := NewShellProgramFunc("docker-credential-secretservice")
128+
129+
creds, err := Get(p, "https://example.com")
130+
if err != nil {
131+
fmt.Println(err)
132+
}
133+
134+
fmt.Printf("Got credentials for user `%s` in `%s`\n", creds.Username, creds.ServerURL)
135+
}
136+
137+
func TestGet(t *testing.T) {
138+
valid := []credentials.Credentials{
139+
{validServerAddress, "foo", "bar"},
140+
{validServerAddress2, "<token>", "abcd1234"},
141+
}
142+
143+
for _, v := range valid {
144+
c, err := Get(mockProgramFn, v.ServerURL)
145+
if err != nil {
146+
t.Fatal(err)
147+
}
148+
149+
if c.Username != v.Username {
150+
t.Fatalf("expected username `%s`, got %s", v.Username, c.Username)
151+
}
152+
if c.Secret != v.Secret {
153+
t.Fatalf("expected secret `%s`, got %s", v.Secret, c.Secret)
154+
}
155+
}
156+
157+
invalid := []struct {
158+
serverURL string
159+
err string
160+
}{
161+
{missingCredsAddress, credentials.NewErrCredentialsNotFound().Error()},
162+
{invalidServerAddress, "error getting credentials - err: exited 1, out: `program failed`"},
163+
}
164+
165+
for _, v := range invalid {
166+
_, err := Get(mockProgramFn, v.serverURL)
167+
if err == nil {
168+
t.Fatalf("Expected error for server %s, got nil", v.serverURL)
169+
}
170+
if err.Error() != v.err {
171+
t.Fatalf("Expected error `%s`, got `%v`", v.err, err)
172+
}
173+
}
174+
}
175+
176+
func ExampleErase() {
177+
p := NewShellProgramFunc("docker-credential-secretservice")
178+
179+
if err := Erase(p, "https://example.com"); err != nil {
180+
fmt.Println(err)
181+
}
182+
}
183+
184+
func TestErase(t *testing.T) {
185+
if err := Erase(mockProgramFn, validServerAddress); err != nil {
186+
t.Fatal(err)
187+
}
188+
189+
if err := Erase(mockProgramFn, invalidServerAddress); err == nil {
190+
t.Fatalf("Expected error for server %s, got nil", invalidServerAddress)
191+
}
192+
}

client/command.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package client
2+
3+
import (
4+
"io"
5+
"os/exec"
6+
)
7+
8+
// Program is an interface to execute external programs.
9+
type Program interface {
10+
Output() ([]byte, error)
11+
Input(in io.Reader)
12+
}
13+
14+
// ProgramFunc is a type of function that initializes programs based on arguments.
15+
type ProgramFunc func(args ...string) Program
16+
17+
// NewShellProgramFunc creates programs that are executed in a Shell.
18+
func NewShellProgramFunc(name string) ProgramFunc {
19+
return func(args ...string) Program {
20+
return &Shell{cmd: exec.Command(name, args...)}
21+
}
22+
}
23+
24+
// Shell invokes shell commands to talk with a remote credentials helper.
25+
type Shell struct {
26+
cmd *exec.Cmd
27+
}
28+
29+
// Output returns responses from the remote credentials helper.
30+
func (s *Shell) Output() ([]byte, error) {
31+
return s.cmd.Output()
32+
}
33+
34+
// Input sets the input to send to a remote credentials helper.
35+
func (s *Shell) Input(in io.Reader) {
36+
s.cmd.Stdin = in
37+
}

credentials/credentials.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import (
1010
"strings"
1111
)
1212

13+
// Credentials holds the information shared between docker and the credentials store.
14+
type Credentials struct {
15+
ServerURL string
16+
Username string
17+
Secret string
18+
}
19+
1320
// Serve initializes the credentials helper and parses the action argument.
1421
// This function is designed to be called from a command line interface.
1522
// It uses os.Args[1] as the key for the action.

credentials/error.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package credentials
2+
3+
// ErrCredentialsNotFound standarizes the not found error, so every helper returns
4+
// the same message and docker can handle it properly.
5+
const errCredentialsNotFoundMessage = "credentials not found in native keychain"
6+
7+
// errCredentialsNotFound represents an error
8+
// raised when credentials are not in the store.
9+
type errCredentialsNotFound struct{}
10+
11+
// Error returns the standard error message
12+
// for when the credentials are not in the store.
13+
func (errCredentialsNotFound) Error() string {
14+
return errCredentialsNotFoundMessage
15+
}
16+
17+
// NewErrCredentialsNotFound creates a new error
18+
// for when the credentials are not in the store.
19+
func NewErrCredentialsNotFound() error {
20+
return errCredentialsNotFound{}
21+
}
22+
23+
// IsErrCredentialsNotFound returns true if the error
24+
// was caused by not having a set of credentials in a store.
25+
func IsErrCredentialsNotFound(err error) bool {
26+
_, ok := err.(errCredentialsNotFound)
27+
return ok
28+
}
29+
30+
// IsErrCredentialsNotFoundMessage returns true if the error
31+
// was caused by not having a set of credentials in a store.
32+
//
33+
// This function helps to check messages returned by an
34+
// external program via its standard output.
35+
func IsErrCredentialsNotFoundMessage(err string) bool {
36+
return err == errCredentialsNotFoundMessage
37+
}

0 commit comments

Comments
 (0)