Implement archive provider and "archive_file" resource. (#7322)
This commit is contained in:
parent
725d60ab57
commit
70cadcf31d
|
@ -0,0 +1,12 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform/builtin/providers/archive"
|
||||
"github.com/hashicorp/terraform/plugin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
plugin.Serve(&plugin.ServeOpts{
|
||||
ProviderFunc: archive.Provider,
|
||||
})
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,47 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Archiver interface {
|
||||
ArchiveContent(content []byte, infilename string) error
|
||||
ArchiveFile(infilename string) error
|
||||
ArchiveDir(indirname string) error
|
||||
}
|
||||
|
||||
type ArchiverBuilder func(filepath string) Archiver
|
||||
|
||||
var archiverBuilders = map[string]ArchiverBuilder{
|
||||
"zip": NewZipArchiver,
|
||||
}
|
||||
|
||||
func getArchiver(archiveType string, filepath string) Archiver {
|
||||
if builder, ok := archiverBuilders[archiveType]; ok {
|
||||
return builder(filepath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertValidFile(infilename string) (os.FileInfo, error) {
|
||||
fi, err := os.Stat(infilename)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
return fi, fmt.Errorf("could not archive missing file: %s", infilename)
|
||||
}
|
||||
return fi, err
|
||||
}
|
||||
|
||||
func assertValidDir(indirname string) (os.FileInfo, error) {
|
||||
fi, err := os.Stat(indirname)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fi, fmt.Errorf("could not archive missing directory: %s", indirname)
|
||||
}
|
||||
return fi, err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return fi, fmt.Errorf("could not archive directory that is a file: %s", indirname)
|
||||
}
|
||||
return fi, nil
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform/helper/schema"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
)
|
||||
|
||||
func Provider() terraform.ResourceProvider {
|
||||
return &schema.Provider{
|
||||
Schema: map[string]*schema.Schema{},
|
||||
|
||||
ResourcesMap: map[string]*schema.Resource{
|
||||
"archive_file": resourceArchiveFile(),
|
||||
},
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform/helper/schema"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
)
|
||||
|
||||
var testProviders = map[string]terraform.ResourceProvider{
|
||||
"archive": Provider(),
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
if err := Provider().(*schema.Provider).InternalValidate(); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,174 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"github.com/hashicorp/terraform/helper/schema"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
)
|
||||
|
||||
func resourceArchiveFile() *schema.Resource {
|
||||
return &schema.Resource{
|
||||
Create: resourceArchiveFileCreate,
|
||||
Read: resourceArchiveFileRead,
|
||||
Update: resourceArchiveFileUpdate,
|
||||
Delete: resourceArchiveFileDelete,
|
||||
Exists: resourceArchiveFileExists,
|
||||
|
||||
Schema: map[string]*schema.Schema{
|
||||
"type": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Required: true,
|
||||
ForceNew: true,
|
||||
},
|
||||
"source_content": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
ForceNew: true,
|
||||
ConflictsWith: []string{"source_file", "source_dir"},
|
||||
},
|
||||
"source_content_filename": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
ForceNew: true,
|
||||
ConflictsWith: []string{"source_file", "source_dir"},
|
||||
},
|
||||
"source_file": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
ForceNew: true,
|
||||
ConflictsWith: []string{"source_content", "source_content_filename", "source_dir"},
|
||||
},
|
||||
"source_dir": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
ForceNew: true,
|
||||
ConflictsWith: []string{"source_content", "source_content_filename", "source_file"},
|
||||
},
|
||||
"output_path": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Required: true,
|
||||
},
|
||||
"output_size": &schema.Schema{
|
||||
Type: schema.TypeInt,
|
||||
Computed: true,
|
||||
ForceNew: true,
|
||||
},
|
||||
"output_sha": &schema.Schema{
|
||||
Type: schema.TypeString,
|
||||
Computed: true,
|
||||
ForceNew: true,
|
||||
Description: "SHA1 checksum of output file",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resourceArchiveFileCreate(d *schema.ResourceData, meta interface{}) error {
|
||||
if err := resourceArchiveFileUpdate(d, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return resourceArchiveFileRead(d, meta)
|
||||
}
|
||||
|
||||
func resourceArchiveFileRead(d *schema.ResourceData, meta interface{}) error {
|
||||
output_path := d.Get("output_path").(string)
|
||||
fi, err := os.Stat(output_path)
|
||||
if os.IsNotExist(err) {
|
||||
d.SetId("")
|
||||
d.MarkNewResource()
|
||||
return nil
|
||||
}
|
||||
|
||||
sha, err := genFileSha1(fi.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate file checksum sha: %s", err)
|
||||
}
|
||||
d.Set("output_sha", sha)
|
||||
d.Set("output_size", fi.Size())
|
||||
d.SetId(d.Get("output_sha").(string))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceArchiveFileUpdate(d *schema.ResourceData, meta interface{}) error {
|
||||
archiveType := d.Get("type").(string)
|
||||
outputPath := d.Get("output_path").(string)
|
||||
|
||||
archiver := getArchiver(archiveType, outputPath)
|
||||
if archiver == nil {
|
||||
return fmt.Errorf("archive type not supported: %s", archiveType)
|
||||
}
|
||||
|
||||
if dir, ok := d.GetOk("source_dir"); ok {
|
||||
if err := archiver.ArchiveDir(dir.(string)); err != nil {
|
||||
return fmt.Errorf("error archiving directory: %s", err)
|
||||
}
|
||||
} else if file, ok := d.GetOk("source_file"); ok {
|
||||
if err := archiver.ArchiveFile(file.(string)); err != nil {
|
||||
return fmt.Errorf("error archiving file: %s", err)
|
||||
}
|
||||
} else if filename, ok := d.GetOk("source_content_filename"); ok {
|
||||
content := d.Get("source_content").(string)
|
||||
if err := archiver.ArchiveContent([]byte(content), filename.(string)); err != nil {
|
||||
return fmt.Errorf("error archiving content: %s", err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("one of 'source_dir', 'source_file', 'source_content_filename' must be specified")
|
||||
}
|
||||
|
||||
// Generate archived file stats
|
||||
output_path := d.Get("output_path").(string)
|
||||
fi, err := os.Stat(output_path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sha, err := genFileSha1(fi.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate file checksum sha: %s", err)
|
||||
}
|
||||
d.Set("output_sha", sha)
|
||||
d.Set("output_size", fi.Size())
|
||||
d.SetId(d.Get("output_sha").(string))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceArchiveFileDelete(d *schema.ResourceData, meta interface{}) error {
|
||||
output_path := d.Get("output_path").(string)
|
||||
fi, err := os.Stat(output_path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Remove(fi.Name()); err != nil {
|
||||
return fmt.Errorf("could not delete zip file '%s': %s", fi.Name(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceArchiveFileExists(d *schema.ResourceData, meta interface{}) (bool, error) {
|
||||
output_path := d.Get("output_path").(string)
|
||||
_, err := os.Stat(output_path)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func genFileSha1(filename string) (string, error) {
|
||||
data, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not compute file '%s' checksum: %s", filename, err)
|
||||
}
|
||||
h := sha1.New()
|
||||
h.Write([]byte(data))
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
r "github.com/hashicorp/terraform/helper/resource"
|
||||
"github.com/hashicorp/terraform/terraform"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccArchiveFile_Basic(t *testing.T) {
|
||||
var fileSize string
|
||||
r.Test(t, r.TestCase{
|
||||
Providers: testProviders,
|
||||
CheckDestroy: r.ComposeTestCheckFunc(
|
||||
testAccArchiveFileMissing("zip_file_acc_test.zip"),
|
||||
),
|
||||
Steps: []r.TestStep{
|
||||
r.TestStep{
|
||||
Config: testAccArchiveFileContentConfig,
|
||||
Check: r.ComposeTestCheckFunc(
|
||||
testAccArchiveFileExists("zip_file_acc_test.zip", &fileSize),
|
||||
r.TestCheckResourceAttrPtr("archive_file.foo", "output_size", &fileSize),
|
||||
),
|
||||
},
|
||||
r.TestStep{
|
||||
Config: testAccArchiveFileFileConfig,
|
||||
Check: r.ComposeTestCheckFunc(
|
||||
testAccArchiveFileExists("zip_file_acc_test.zip", &fileSize),
|
||||
r.TestCheckResourceAttrPtr("archive_file.foo", "output_size", &fileSize),
|
||||
),
|
||||
},
|
||||
r.TestStep{
|
||||
Config: testAccArchiveFileDirConfig,
|
||||
Check: r.ComposeTestCheckFunc(
|
||||
testAccArchiveFileExists("zip_file_acc_test.zip", &fileSize),
|
||||
r.TestCheckResourceAttrPtr("archive_file.foo", "output_size", &fileSize),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func testAccArchiveFileExists(filename string, fileSize *string) r.TestCheckFunc {
|
||||
return func(s *terraform.State) error {
|
||||
*fileSize = ""
|
||||
fi, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*fileSize = fmt.Sprintf("%d", fi.Size())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func testAccArchiveFileMissing(filename string) r.TestCheckFunc {
|
||||
return func(s *terraform.State) error {
|
||||
_, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("found file expected to be deleted: %s", filename)
|
||||
}
|
||||
}
|
||||
|
||||
var testAccArchiveFileContentConfig = `
|
||||
resource "archive_file" "foo" {
|
||||
type = "zip"
|
||||
source_content = "This is some content"
|
||||
source_content_filename = "content.txt"
|
||||
output_path = "zip_file_acc_test.zip"
|
||||
}
|
||||
`
|
||||
|
||||
var testAccArchiveFileFileConfig = `
|
||||
resource "archive_file" "foo" {
|
||||
type = "zip"
|
||||
source_file = "test-fixtures/test-file.txt"
|
||||
output_path = "zip_file_acc_test.zip"
|
||||
}
|
||||
`
|
||||
|
||||
var testAccArchiveFileDirConfig = `
|
||||
resource "archive_file" "foo" {
|
||||
type = "zip"
|
||||
source_dir = "test-fixtures/test-dir"
|
||||
output_path = "zip_file_acc_test.zip"
|
||||
}
|
||||
`
|
|
@ -0,0 +1 @@
|
|||
This is file 1
|
|
@ -0,0 +1 @@
|
|||
This is file 2
|
|
@ -0,0 +1 @@
|
|||
This is file 3
|
|
@ -0,0 +1 @@
|
|||
This is test content
|
|
@ -0,0 +1,107 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type ZipArchiver struct {
|
||||
filepath string
|
||||
filewriter *os.File
|
||||
writer *zip.Writer
|
||||
}
|
||||
|
||||
func NewZipArchiver(filepath string) Archiver {
|
||||
return &ZipArchiver{
|
||||
filepath: filepath,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ZipArchiver) ArchiveContent(content []byte, infilename string) error {
|
||||
if err := a.open(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.close()
|
||||
|
||||
f, err := a.writer.Create(infilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = f.Write(content)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *ZipArchiver) ArchiveFile(infilename string) error {
|
||||
fi, err := assertValidFile(infilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := ioutil.ReadFile(infilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return a.ArchiveContent(content, fi.Name())
|
||||
}
|
||||
|
||||
func (a *ZipArchiver) ArchiveDir(indirname string) error {
|
||||
_, err := assertValidDir(indirname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.open(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.close()
|
||||
|
||||
return filepath.Walk(indirname, func(path string, info os.FileInfo, err error) error {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relname, err := filepath.Rel(indirname, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error relativizing file for archival: %s", err)
|
||||
}
|
||||
f, err := a.writer.Create(relname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating file inside archive: %s", err)
|
||||
}
|
||||
content, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading file for archival: %s", err)
|
||||
}
|
||||
_, err = f.Write(content)
|
||||
return err
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (a *ZipArchiver) open() error {
|
||||
f, err := os.Create(a.filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.filewriter = f
|
||||
a.writer = zip.NewWriter(f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ZipArchiver) close() {
|
||||
if a.writer != nil {
|
||||
a.writer.Close()
|
||||
a.writer = nil
|
||||
}
|
||||
if a.filewriter != nil {
|
||||
a.filewriter.Close()
|
||||
a.filewriter = nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestZipArchiver_Content(t *testing.T) {
|
||||
zipfilepath := "archive-content.zip"
|
||||
archiver := NewZipArchiver(zipfilepath)
|
||||
if err := archiver.ArchiveContent([]byte("This is some content"), "content.txt"); err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
|
||||
ensureContents(t, zipfilepath, map[string][]byte{
|
||||
"content.txt": []byte("This is some content"),
|
||||
})
|
||||
}
|
||||
|
||||
func TestZipArchiver_File(t *testing.T) {
|
||||
zipfilepath := "archive-file.zip"
|
||||
archiver := NewZipArchiver(zipfilepath)
|
||||
if err := archiver.ArchiveFile("./test-fixtures/test-file.txt"); err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
|
||||
ensureContents(t, zipfilepath, map[string][]byte{
|
||||
"test-file.txt": []byte("This is test content"),
|
||||
})
|
||||
}
|
||||
|
||||
func TestZipArchiver_Dir(t *testing.T) {
|
||||
zipfilepath := "archive-dir.zip"
|
||||
archiver := NewZipArchiver(zipfilepath)
|
||||
if err := archiver.ArchiveDir("./test-fixtures/test-dir"); err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
|
||||
ensureContents(t, zipfilepath, map[string][]byte{
|
||||
"file1.txt": []byte("This is file 1"),
|
||||
"file2.txt": []byte("This is file 2"),
|
||||
"file3.txt": []byte("This is file 3"),
|
||||
})
|
||||
}
|
||||
|
||||
func ensureContents(t *testing.T, zipfilepath string, wants map[string][]byte) {
|
||||
r, err := zip.OpenReader(zipfilepath)
|
||||
if err != nil {
|
||||
t.Fatalf("could not open zip file: %s", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if len(r.File) != len(wants) {
|
||||
t.Errorf("mismatched file count, got %d, want %d", len(r.File), len(wants))
|
||||
}
|
||||
for _, cf := range r.File {
|
||||
ensureContent(t, wants, cf)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureContent(t *testing.T, wants map[string][]byte, got *zip.File) {
|
||||
want, ok := wants[got.Name]
|
||||
if !ok {
|
||||
t.Errorf("additional file in zip: %s", got.Name)
|
||||
return
|
||||
}
|
||||
|
||||
r, err := got.Open()
|
||||
if err != nil {
|
||||
t.Errorf("could not open file: %s", err)
|
||||
}
|
||||
defer r.Close()
|
||||
gotContentBytes, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Errorf("could not read file: %s", err)
|
||||
}
|
||||
|
||||
wantContent := string(want)
|
||||
gotContent := string(gotContentBytes)
|
||||
if gotContent != wantContent {
|
||||
t.Errorf("mismatched content\ngot\n%s\nwant\n%s", gotContent, wantContent)
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
archiveprovider "github.com/hashicorp/terraform/builtin/providers/archive"
|
||||
atlasprovider "github.com/hashicorp/terraform/builtin/providers/atlas"
|
||||
awsprovider "github.com/hashicorp/terraform/builtin/providers/aws"
|
||||
azureprovider "github.com/hashicorp/terraform/builtin/providers/azure"
|
||||
|
@ -104,6 +105,7 @@ var InternalProviders = map[string]plugin.ProviderFunc{
|
|||
"ultradns": ultradnsprovider.Provider,
|
||||
"vcd": vcdprovider.Provider,
|
||||
"vsphere": vsphereprovider.Provider,
|
||||
"archive": archiveprovider.Provider,
|
||||
}
|
||||
|
||||
var InternalProvisioners = map[string]plugin.ProvisionerFunc{
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
layout: "archive"
|
||||
page_title: "Provider: Archive"
|
||||
sidebar_current: "docs-archive-index"
|
||||
description: |-
|
||||
The Archive provider is used to manage archive files.
|
||||
---
|
||||
|
||||
# Archive Provider
|
||||
|
||||
The archive provider exposes resources to manage archive files.
|
||||
|
||||
Use the navigation to the left to read about the available resources.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
provider "archive" {
|
||||
}
|
||||
```
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
layout: "archive"
|
||||
page_title: "Archive: archive_file"
|
||||
sidebar_current: "docs-archive-resource-file"
|
||||
description: |-
|
||||
Generates an archive from content, a file, or directory of files.
|
||||
---
|
||||
|
||||
# archive\_file
|
||||
|
||||
Generates an archive from content, a file, or directory of files.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
resource "archive_file" "init" {
|
||||
template = "${file("${path.module}/init.tpl")}"
|
||||
}
|
||||
```
|
||||
|
||||
## Argument Reference
|
||||
|
||||
The following arguments are supported:
|
||||
|
||||
NOTE: One of `source_content_filename` (with `source_content`), `source_file`, or `source_dir` must be specified.
|
||||
|
||||
* `type` - (required) The type of archive to generate.
|
||||
NOTE: `archive` is supported.
|
||||
|
||||
* `output_path` - (required) The output of the archive file.
|
||||
|
||||
* `source_content` - (optional) Add only this content to the archive with `source_content_filename` as the filename.
|
||||
|
||||
* `source_content_filename` - (optional) Set this as the filename when using `source_content`.
|
||||
|
||||
* `source_file` - (optional) Package this file into the archive.
|
||||
|
||||
* `source_dir` - (optional) Package entire contents of this directory into the archive.
|
||||
|
||||
## Attributes Reference
|
||||
|
||||
The following attributes are exported:
|
||||
|
||||
* `output_size` - The size of the output archive file.
|
||||
* `output_sha` - The SHA1 checksum of output archive file.
|
|
@ -0,0 +1,26 @@
|
|||
<% wrap_layout :inner do %>
|
||||
<% content_for :sidebar do %>
|
||||
<div class="docs-sidebar hidden-print affix-top" role="complementary">
|
||||
<ul class="nav docs-sidenav">
|
||||
<li<%= sidebar_current("docs-home") %>>
|
||||
<a href="/docs/providers/index.html">« Documentation Home</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current("docs-archive-index") %>>
|
||||
<a href="/docs/providers/archive/index.html">Template Provider</a>
|
||||
</li>
|
||||
|
||||
<li<%= sidebar_current(/^docs-archive-resource/) %>>
|
||||
<a href="#">Resources</a>
|
||||
<ul class="nav nav-visible">
|
||||
<li<%= sidebar_current("docs-archive-resource-file") %>>
|
||||
<a href="/docs/providers/archive/r/file.html">archive_file</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= yield %>
|
||||
<% end %>
|
Loading…
Reference in New Issue